@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.
Files changed (76) hide show
  1. package/package.json +9 -1
  2. package/scripts/postinstall-lock.ts +153 -153
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/agent/__tests__/context.test.ts +49 -1
  5. package/src/agent/context.ts +535 -535
  6. package/src/agent/index.ts +6 -6
  7. package/src/agent/plan.ts +282 -282
  8. package/src/agent/repair.ts +171 -171
  9. package/src/agent/sync.ts +200 -200
  10. package/src/agent/types.ts +8 -0
  11. package/src/agent/verify.ts +100 -2
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +5 -4
  14. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  15. package/src/bundler/__tests__/css.test.ts +20 -20
  16. package/src/bundler/analyzer.ts +15 -15
  17. package/src/bundler/build.test.ts +66 -15
  18. package/src/bundler/build.ts +165 -103
  19. package/src/bundler/css.ts +42 -42
  20. package/src/bundler/manifest-schema.ts +21 -21
  21. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  22. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  23. package/src/bundler/types.ts +31 -31
  24. package/src/client/island.ts +79 -79
  25. package/src/config/validate.ts +1 -1
  26. package/src/contract/schema.ts +7 -0
  27. package/src/deploy/inference/context.ts +82 -82
  28. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  29. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  30. package/src/error/formatter.ts +10 -1
  31. package/src/experimental/index.ts +10 -0
  32. package/src/filling/context.ts +17 -17
  33. package/src/filling/filling.ts +22 -1
  34. package/src/filling/index.ts +15 -1
  35. package/src/generator/generate.ts +30 -30
  36. package/src/generator/index.ts +3 -3
  37. package/src/generator/templates.ts +210 -210
  38. package/src/guard/check.ts +9 -9
  39. package/src/guard/config-guard.ts +13 -13
  40. package/src/guard/fs-routes-policy.ts +51 -51
  41. package/src/guard/index.ts +11 -11
  42. package/src/index.ts +0 -10
  43. package/src/internal/index.ts +25 -0
  44. package/src/kitchen/api/file-api.ts +11 -11
  45. package/src/report/index.ts +1 -1
  46. package/src/resource/__tests__/generator.test.ts +6 -6
  47. package/src/resource/__tests__/schema.test.ts +14 -14
  48. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  49. package/src/resource/ddl/emit.ts +146 -146
  50. package/src/resource/generator-schema.ts +11 -11
  51. package/src/resource/generators/slot.ts +72 -72
  52. package/src/resource/schema.ts +21 -21
  53. package/src/router/client-entry.test.ts +69 -33
  54. package/src/router/client-entry.ts +134 -74
  55. package/src/router/fs-routes.ts +24 -22
  56. package/src/router/fs-scanner.ts +21 -17
  57. package/src/router/fs-types.ts +8 -5
  58. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  59. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  60. package/src/runtime/__tests__/page-render-response.test.ts +103 -103
  61. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  62. package/src/runtime/devtools-adapter.ts +68 -68
  63. package/src/runtime/escape.ts +34 -34
  64. package/src/runtime/image-feature.ts +15 -0
  65. package/src/runtime/observability-lifecycle.ts +290 -290
  66. package/src/runtime/page-render-response.ts +106 -106
  67. package/src/runtime/rate-limit.ts +231 -0
  68. package/src/runtime/request-middleware.ts +31 -31
  69. package/src/runtime/scheduler-lifecycle.ts +64 -0
  70. package/src/runtime/server.ts +27 -295
  71. package/src/runtime/ssr.ts +59 -59
  72. package/src/runtime/static-files.ts +289 -289
  73. package/src/runtime/streaming-ssr.ts +22 -22
  74. package/src/spec/schema.ts +4 -3
  75. package/src/watcher/__tests__/watcher.test.ts +59 -59
  76. 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 ")}`;
@@ -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
- contractModule: z.string().optional(),
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(),