@mandujs/core 0.54.12 → 0.54.13
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 +9 -1
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/run-audit.ts +15 -15
- package/src/agent/__tests__/context.test.ts +49 -1
- package/src/agent/context.ts +535 -535
- package/src/agent/index.ts +6 -6
- package/src/agent/plan.ts +282 -282
- package/src/agent/repair.ts +171 -171
- package/src/agent/sync.ts +200 -200
- package/src/agent/types.ts +8 -0
- package/src/agent/verify.ts +100 -2
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/build-runner.ts +5 -4
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +66 -15
- package/src/bundler/build.ts +165 -103
- package/src/bundler/css.ts +42 -42
- package/src/bundler/manifest-schema.ts +21 -21
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/block-generated-imports.ts +13 -13
- package/src/bundler/types.ts +31 -31
- package/src/client/island.ts +79 -79
- package/src/config/validate.ts +1 -1
- package/src/contract/schema.ts +7 -0
- package/src/deploy/inference/context.ts +82 -82
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/error/formatter.ts +10 -1
- package/src/experimental/index.ts +10 -0
- package/src/filling/context.ts +17 -17
- package/src/filling/filling.ts +22 -1
- package/src/filling/index.ts +15 -1
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +210 -210
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/index.ts +11 -11
- package/src/index.ts +0 -10
- package/src/internal/index.ts +25 -0
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +6 -6
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +69 -33
- package/src/router/client-entry.ts +134 -74
- package/src/router/fs-routes.ts +24 -22
- package/src/router/fs-scanner.ts +21 -17
- package/src/router/fs-types.ts +8 -5
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +103 -103
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/image-feature.ts +15 -0
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/page-render-response.ts +106 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +27 -295
- package/src/runtime/ssr.ts +59 -59
- package/src/runtime/static-files.ts +289 -289
- package/src/runtime/streaming-ssr.ts +22 -22
- package/src/spec/schema.ts +4 -3
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -1,289 +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
|
-
}
|
|
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,16 +613,16 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
613
613
|
}
|
|
614
614
|
</style>`;
|
|
615
615
|
|
|
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
|
-
}
|
|
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
|
+
}
|
|
626
626
|
|
|
627
627
|
// Phase 7.1 R2 Agent D: Fast Refresh preamble. Must land in <head>
|
|
628
628
|
// BEFORE any island script evaluates — the stubs it installs for
|
|
@@ -700,7 +700,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
700
700
|
// 1~8: hydration이 필요한 경우에만 클라이언트 JS 관련 스크립트 삽입
|
|
701
701
|
if (needsHydration) {
|
|
702
702
|
// 1. Critical 데이터 스크립트 (즉시 사용 가능)
|
|
703
|
-
if (criticalData !== undefined && routeId) {
|
|
703
|
+
if (criticalData !== undefined && routeId) {
|
|
704
704
|
const wrappedData = {
|
|
705
705
|
[routeId]: {
|
|
706
706
|
serverData: criticalData,
|
|
@@ -749,16 +749,16 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
749
749
|
|
|
750
750
|
// 6. Island modulepreload
|
|
751
751
|
const bundle = bundleManifest.bundles[routeId];
|
|
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
|
-
}
|
|
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
|
+
}
|
|
762
762
|
|
|
763
763
|
// 7. Runtime 로드
|
|
764
764
|
if (bundleManifest.shared.runtime) {
|
|
@@ -792,7 +792,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
792
792
|
}
|
|
793
793
|
|
|
794
794
|
// Island wrapper 닫기 (hydration이 필요한 경우)
|
|
795
|
-
const islandCloseTag = needsHydration && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
|
|
795
|
+
const islandCloseTag = needsHydration && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
|
|
796
796
|
|
|
797
797
|
return `${islandCloseTag}</div>
|
|
798
798
|
${scripts.join("\n ")}`;
|
package/src/spec/schema.ts
CHANGED
|
@@ -72,9 +72,10 @@ const RouteSpecBase = {
|
|
|
72
72
|
id: z.string().min(1, "id는 필수입니다"),
|
|
73
73
|
pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
|
|
74
74
|
module: z.string().min(1, "module 경로는 필수입니다"),
|
|
75
|
-
slotModule: z.string().optional(),
|
|
76
|
-
clientModule: z.string().optional(),
|
|
77
|
-
|
|
75
|
+
slotModule: z.string().optional(),
|
|
76
|
+
clientModule: z.string().optional(),
|
|
77
|
+
clientExportName: z.string().optional(),
|
|
78
|
+
contractModule: z.string().optional(),
|
|
78
79
|
hydration: HydrationConfig.optional(),
|
|
79
80
|
loader: LoaderConfig.optional(),
|
|
80
81
|
streaming: z.boolean().optional(),
|