@mandujs/core 0.54.1 → 0.54.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/build.ts +26 -19
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- 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/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 +54 -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 +110 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +20 -7
- package/src/runtime/static-files.ts +289 -0
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";
|
|
@@ -612,8 +612,9 @@ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
|
|
|
612
612
|
return React.cloneElement(element, undefined, resolvedChildren);
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
616
|
-
const
|
|
615
|
+
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
616
|
+
const hasExplicitTitle = options.title !== undefined;
|
|
617
|
+
const {
|
|
617
618
|
title = "Mandu App",
|
|
618
619
|
lang = "ko",
|
|
619
620
|
serverData,
|
|
@@ -793,7 +794,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
793
794
|
? `<script>window.__MANDU_SPA__=false;</script>`
|
|
794
795
|
: "";
|
|
795
796
|
|
|
796
|
-
// #179: body 내 <link> 태그를 <head>로 호이스팅
|
|
797
|
+
// #179: body 내 <link> 태그를 <head>로 호이스팅
|
|
797
798
|
// React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
|
|
798
799
|
// 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
|
|
799
800
|
const linkTagPattern = /<link\s[^>]*(?:rel=["'](?:stylesheet|preconnect|preload|icon|dns-prefetch)["'][^>]*|href=["'][^"']+["'][^>]*)\/?\s*>/gi;
|
|
@@ -802,7 +803,19 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
802
803
|
hoistedLinks.push(match);
|
|
803
804
|
return "";
|
|
804
805
|
});
|
|
805
|
-
const hoistedLinkTags = hoistedLinks.join("\n ");
|
|
806
|
+
const hoistedLinkTags = hoistedLinks.join("\n ");
|
|
807
|
+
|
|
808
|
+
// #273 F15 — React 19 renders document metadata such as <title> from a
|
|
809
|
+
// page component into the body string in this SSR path. Hoist the first
|
|
810
|
+
// body title into <head> when no metadata/generateMetadata title was
|
|
811
|
+
// provided, and always strip body titles to avoid duplicate/invalid HTML.
|
|
812
|
+
let effectiveTitle = title;
|
|
813
|
+
const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i;
|
|
814
|
+
const bodyTitleMatch = bodyContent.match(titleTagPattern);
|
|
815
|
+
const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, "");
|
|
816
|
+
if (!hasExplicitTitle && bodyTitleMatch) {
|
|
817
|
+
effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title);
|
|
818
|
+
}
|
|
806
819
|
|
|
807
820
|
// Phase 18.α — Dev Error Overlay injection.
|
|
808
821
|
// Only emitted when `isDev` AND the user has not opted out (via
|
|
@@ -820,7 +833,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
820
833
|
<head>
|
|
821
834
|
<meta charset="UTF-8">
|
|
822
835
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
823
|
-
<title>${escapeHtmlText(
|
|
836
|
+
<title>${escapeHtmlText(effectiveTitle)}</title>
|
|
824
837
|
${cssLinkTag}
|
|
825
838
|
${viewTransitionTag}
|
|
826
839
|
${prefetchScriptTag}
|
|
@@ -832,7 +845,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
832
845
|
${devErrorOverlayTag}
|
|
833
846
|
</head>
|
|
834
847
|
<body>
|
|
835
|
-
<div id="root"${rootAttrs}>${
|
|
848
|
+
<div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
|
|
836
849
|
${dataScript}
|
|
837
850
|
${routeScript}
|
|
838
851
|
${hydrationScripts}
|
|
@@ -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
|
+
}
|