@mandujs/core 0.18.22 → 0.19.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.
Files changed (91) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Mandu SSR Cache Layer
3
+ * ISR(Incremental Static Regeneration) + SWR(Stale-While-Revalidate) 지원
4
+ */
5
+
6
+ // ========== Types ==========
7
+
8
+ export interface CacheEntry {
9
+ /** 렌더링된 HTML */
10
+ html: string;
11
+ /** 직렬화된 loader 데이터 */
12
+ loaderData: unknown;
13
+ /** 응답 상태 코드 */
14
+ status: number;
15
+ /** 응답 헤더 */
16
+ headers: Record<string, string>;
17
+ /** 생성 시간 (ms) */
18
+ createdAt: number;
19
+ /** stale이 되는 시간 (ms) — createdAt + revalidate * 1000 */
20
+ revalidateAfter: number;
21
+ /** 무효화 태그 */
22
+ tags: string[];
23
+ }
24
+
25
+ export type CacheStatus = "HIT" | "STALE" | "MISS";
26
+
27
+ export interface CacheLookupResult {
28
+ status: CacheStatus;
29
+ entry: CacheEntry | null;
30
+ }
31
+
32
+ export interface CacheStoreStats {
33
+ entries: number;
34
+ maxEntries?: number;
35
+ staleEntries?: number;
36
+ hits?: number;
37
+ staleHits?: number;
38
+ misses?: number;
39
+ hitRate?: number;
40
+ }
41
+
42
+ export interface CacheStore {
43
+ get(key: string): CacheEntry | null;
44
+ set(key: string, entry: CacheEntry): void;
45
+ delete(key: string): void;
46
+ /** pathname 부분 매칭으로 캐시 삭제 (키 형식: "routeId:pathname") */
47
+ deleteByPath(pathname: string): void;
48
+ deleteByTag(tag: string): void;
49
+ clear(): void;
50
+ readonly size: number;
51
+ }
52
+
53
+ // ========== Memory Cache (LRU) ==========
54
+
55
+ export class MemoryCacheStore implements CacheStore {
56
+ private cache = new Map<string, CacheEntry>();
57
+ private tagIndex = new Map<string, Set<string>>();
58
+ private readonly maxEntries: number;
59
+ private hits = 0;
60
+ private staleHits = 0;
61
+ private misses = 0;
62
+
63
+ constructor(maxEntries: number = 1000) {
64
+ this.maxEntries = maxEntries;
65
+ }
66
+
67
+ get size(): number {
68
+ return this.cache.size;
69
+ }
70
+
71
+ get(key: string): CacheEntry | null {
72
+ return this.cache.get(key) ?? null;
73
+ }
74
+
75
+ /** LRU 접근 — HIT 확인 후에만 호출하여 stale 엔트리가 승격되지 않도록 */
76
+ touch(key: string): void {
77
+ const entry = this.cache.get(key);
78
+ if (entry) {
79
+ this.cache.delete(key);
80
+ this.cache.set(key, entry);
81
+ }
82
+ }
83
+
84
+ set(key: string, entry: CacheEntry): void {
85
+ // 기존 엔트리 태그 인덱스 정리
86
+ if (this.cache.has(key)) {
87
+ this.removeFromTagIndex(key);
88
+ }
89
+
90
+ // LRU eviction
91
+ if (this.cache.size >= this.maxEntries) {
92
+ const oldest = this.cache.keys().next().value;
93
+ if (oldest !== undefined) {
94
+ this.removeFromTagIndex(oldest);
95
+ this.cache.delete(oldest);
96
+ }
97
+ }
98
+
99
+ this.cache.set(key, entry);
100
+
101
+ // 태그 인덱스 업데이트
102
+ for (const tag of entry.tags) {
103
+ let keys = this.tagIndex.get(tag);
104
+ if (!keys) {
105
+ keys = new Set();
106
+ this.tagIndex.set(tag, keys);
107
+ }
108
+ keys.add(key);
109
+ }
110
+ }
111
+
112
+ delete(key: string): void {
113
+ this.removeFromTagIndex(key);
114
+ this.cache.delete(key);
115
+ }
116
+
117
+ deleteByPath(pathname: string): void {
118
+ // 캐시 키 형식: "routeId:pathname?query" — pathname 부분이 일치하는 모든 키 삭제
119
+ const keysToDelete: string[] = [];
120
+ for (const key of this.cache.keys()) {
121
+ const keyPath = getCachePathname(key);
122
+ if (keyPath === pathname) {
123
+ keysToDelete.push(key);
124
+ }
125
+ }
126
+ for (const key of keysToDelete) {
127
+ this.delete(key);
128
+ }
129
+ }
130
+
131
+ deleteByTag(tag: string): void {
132
+ const keys = this.tagIndex.get(tag);
133
+ if (!keys) return;
134
+
135
+ for (const key of keys) {
136
+ this.cache.delete(key);
137
+ // 해당 key가 다른 태그에도 있으면 거기서도 제거
138
+ for (const [otherTag, otherKeys] of this.tagIndex) {
139
+ if (otherTag !== tag) {
140
+ otherKeys.delete(key);
141
+ }
142
+ }
143
+ }
144
+ this.tagIndex.delete(tag);
145
+ }
146
+
147
+ clear(): void {
148
+ this.cache.clear();
149
+ this.tagIndex.clear();
150
+ }
151
+
152
+ recordHit(): void {
153
+ this.hits += 1;
154
+ }
155
+
156
+ recordStale(): void {
157
+ this.staleHits += 1;
158
+ }
159
+
160
+ recordMiss(): void {
161
+ this.misses += 1;
162
+ }
163
+
164
+ getStats(): CacheStoreStats {
165
+ const now = Date.now();
166
+ const staleEntries = Array.from(this.cache.values()).filter((entry) => entry.revalidateAfter <= now).length;
167
+ const totalLookups = this.hits + this.staleHits + this.misses;
168
+
169
+ return {
170
+ entries: this.cache.size,
171
+ maxEntries: this.maxEntries,
172
+ staleEntries,
173
+ hits: this.hits,
174
+ staleHits: this.staleHits,
175
+ misses: this.misses,
176
+ hitRate: totalLookups > 0 ? this.hits / totalLookups : undefined,
177
+ };
178
+ }
179
+
180
+ private removeFromTagIndex(key: string): void {
181
+ const entry = this.cache.get(key);
182
+ if (!entry) return;
183
+ for (const tag of entry.tags) {
184
+ this.tagIndex.get(tag)?.delete(key);
185
+ }
186
+ }
187
+ }
188
+
189
+ function getCachePathname(key: string): string {
190
+ const colonIdx = key.indexOf(":");
191
+ const rawPath = colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
192
+ try {
193
+ return new URL(rawPath, "http://mandu.local").pathname;
194
+ } catch {
195
+ const queryIdx = rawPath.indexOf("?");
196
+ return queryIdx >= 0 ? rawPath.slice(0, queryIdx) : rawPath;
197
+ }
198
+ }
199
+
200
+ // ========== Cache Lookup ==========
201
+
202
+ /**
203
+ * 캐시 조회 — HIT / STALE / MISS 판정
204
+ */
205
+ export function lookupCache(store: CacheStore, key: string): CacheLookupResult {
206
+ const entry = store.get(key);
207
+ if (!entry) {
208
+ if ("recordMiss" in store && typeof (store as MemoryCacheStore).recordMiss === "function") {
209
+ (store as MemoryCacheStore).recordMiss();
210
+ }
211
+ return { status: "MISS", entry: null };
212
+ }
213
+
214
+ const now = Date.now();
215
+ if (now < entry.revalidateAfter) {
216
+ if ("recordHit" in store && typeof (store as MemoryCacheStore).recordHit === "function") {
217
+ (store as MemoryCacheStore).recordHit();
218
+ }
219
+ // HIT: LRU 승격 (MemoryCacheStore만 해당)
220
+ if ("touch" in store && typeof (store as MemoryCacheStore).touch === "function") {
221
+ (store as MemoryCacheStore).touch(key);
222
+ }
223
+ return { status: "HIT", entry };
224
+ }
225
+
226
+ if ("recordStale" in store && typeof (store as MemoryCacheStore).recordStale === "function") {
227
+ (store as MemoryCacheStore).recordStale();
228
+ }
229
+ // STALE: LRU 승격하지 않음 — eviction 대상으로 유지
230
+ return { status: "STALE", entry };
231
+ }
232
+
233
+ /**
234
+ * 캐시 엔트리 생성
235
+ */
236
+ export function createCacheEntry(
237
+ html: string,
238
+ loaderData: unknown,
239
+ revalidateSeconds: number,
240
+ tags: string[] = [],
241
+ status: number = 200,
242
+ headers: Record<string, string> = {}
243
+ ): CacheEntry {
244
+ const now = Date.now();
245
+ return {
246
+ html,
247
+ loaderData,
248
+ status,
249
+ headers,
250
+ createdAt: now,
251
+ revalidateAfter: now + revalidateSeconds * 1000,
252
+ tags,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * 캐시된 Response 생성
258
+ */
259
+ export function createCachedResponse(entry: CacheEntry, cacheStatus: CacheStatus): Response {
260
+ const age = Math.floor((Date.now() - entry.createdAt) / 1000);
261
+ return new Response(entry.html, {
262
+ status: entry.status,
263
+ headers: {
264
+ "Content-Type": "text/html; charset=utf-8",
265
+ ...entry.headers,
266
+ "X-Mandu-Cache": cacheStatus,
267
+ "Age": String(age),
268
+ },
269
+ });
270
+ }
271
+
272
+ // ========== Global Cache + Revalidation API ==========
273
+
274
+ let globalCacheStore: CacheStore | null = null;
275
+
276
+ export function setGlobalCache(store: CacheStore): void {
277
+ globalCacheStore = store;
278
+ }
279
+
280
+ export function getGlobalCache(): CacheStore | null {
281
+ return globalCacheStore;
282
+ }
283
+
284
+ /**
285
+ * 특정 경로의 캐시 무효화
286
+ */
287
+ export function revalidatePath(path: string): void {
288
+ if (!globalCacheStore) return;
289
+ globalCacheStore.deleteByPath(path);
290
+ }
291
+
292
+ /**
293
+ * 특정 태그의 모든 캐시 무효화
294
+ */
295
+ export function revalidateTag(tag: string): void {
296
+ if (!globalCacheStore) return;
297
+ globalCacheStore.deleteByTag(tag);
298
+ }
299
+
300
+ export function getCacheStoreStats(store: CacheStore | null): CacheStoreStats | null {
301
+ if (!store) return null;
302
+
303
+ if ("getStats" in store && typeof (store as MemoryCacheStore).getStats === "function") {
304
+ return (store as MemoryCacheStore).getStats();
305
+ }
306
+
307
+ return {
308
+ entries: store.size,
309
+ };
310
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Mandu Fetch Handler Factory
3
+ * 런타임 중립적 fetch handler 생성
4
+ * Bun.serve, Node.js http, Cloudflare Workers 등에서 공통 사용
5
+ */
6
+
7
+ import type { Router } from "./router";
8
+ import type { ServerRegistry } from "./server";
9
+ import type {
10
+ MiddlewareFn,
11
+ MiddlewareConfig,
12
+ InternalMiddlewareContext,
13
+ } from "./middleware";
14
+ import { createMiddlewareContext, getMiddlewareMatch } from "./middleware";
15
+ import { type CorsOptions, isCorsRequest, applyCorsToResponse } from "./cors";
16
+
17
+ export interface FetchHandlerOptions {
18
+ router: Router;
19
+ registry: ServerRegistry;
20
+ corsOptions: CorsOptions | false;
21
+ middlewareFn: MiddlewareFn | null;
22
+ middlewareConfig: MiddlewareConfig | null;
23
+ handleRequest: (req: Request, router: Router, registry: ServerRegistry) => Promise<Response>;
24
+ }
25
+
26
+ /**
27
+ * 런타임 중립적 fetch handler 생성
28
+ * 미들웨어, CORS, 라우트 디스패치를 모두 포함
29
+ */
30
+ export function createFetchHandler(options: FetchHandlerOptions): (req: Request) => Promise<Response> {
31
+ const { router, registry, corsOptions, handleRequest, middlewareFn, middlewareConfig } = options;
32
+
33
+ return async function fetchHandler(req: Request): Promise<Response> {
34
+ // 글로벌 미들웨어 실행 (라우트 매칭 전)
35
+ if (middlewareFn) {
36
+ const url = new URL(req.url);
37
+ const middlewareMatch = getMiddlewareMatch(url.pathname, middlewareConfig);
38
+ if (middlewareMatch.matched) {
39
+ const mwCtx = createMiddlewareContext(req, middlewareMatch.params);
40
+ try {
41
+ const response = await middlewareFn(mwCtx, async () => {
42
+ const rewrittenReq = (mwCtx as InternalMiddlewareContext).getRewrittenRequest();
43
+ return handleRequest(rewrittenReq ?? req, router, registry);
44
+ });
45
+
46
+ if (corsOptions && isCorsRequest(req)) {
47
+ return applyCorsToResponse(response, req, corsOptions);
48
+ }
49
+ return response;
50
+ } catch (error) {
51
+ console.error("[Mandu Middleware] Error:", error);
52
+ return new Response("Internal Server Error", { status: 500 });
53
+ }
54
+ }
55
+ }
56
+
57
+ const response = await handleRequest(req, router, registry);
58
+
59
+ if (corsOptions && isCorsRequest(req)) {
60
+ return applyCorsToResponse(response, req, corsOptions);
61
+ }
62
+
63
+ return response;
64
+ };
65
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Mandu Image Handler
3
+ * /_mandu/image?url=...&w=...&q=... 엔드포인트
4
+ * 온디맨드 리사이즈 + WebP/AVIF 포맷 협상 + 캐시
5
+ */
6
+
7
+ import path from "path";
8
+
9
+ // ========== Types ==========
10
+
11
+ interface ImageOptions {
12
+ width: number;
13
+ quality: number;
14
+ format: "webp" | "jpeg" | "png" | "avif";
15
+ sourceContentType?: string;
16
+ }
17
+
18
+ interface ProcessedImageResult {
19
+ data: Uint8Array;
20
+ contentType: string;
21
+ }
22
+
23
+ // ========== Cache ==========
24
+
25
+ const imageCache = new Map<string, { data: Uint8Array; contentType: string }>();
26
+ const MAX_IMAGE_CACHE = 500;
27
+
28
+ // ========== Handler ==========
29
+
30
+ /**
31
+ * 이미지 최적화 요청 처리
32
+ * /_mandu/image?url=/photos/hero.jpg&w=800&q=80
33
+ */
34
+ export async function handleImageRequest(
35
+ request: Request,
36
+ rootDir: string,
37
+ publicDir: string = "public"
38
+ ): Promise<Response | null> {
39
+ const url = new URL(request.url);
40
+ if (url.pathname !== "/_mandu/image") return null;
41
+
42
+ const src = url.searchParams.get("url");
43
+ const width = Number(url.searchParams.get("w") ?? 800);
44
+ const quality = Number(url.searchParams.get("q") ?? 80);
45
+
46
+ if (!src || width < 1 || width > 4096 || quality < 1 || quality > 100) {
47
+ return new Response("Invalid image parameters", { status: 400 });
48
+ }
49
+
50
+ // 보안: src가 /로 시작하고 traversal/null byte 없는지 확인
51
+ if (!src.startsWith("/") || src.includes("..") || src.includes("\0")) {
52
+ return new Response("Invalid image path", { status: 400 });
53
+ }
54
+
55
+ // 포맷 협상 (Accept 헤더 기반)
56
+ const format = negotiateFormat(request);
57
+ const cacheKey = `${src}:${width}:${quality}:${format}`;
58
+
59
+ // 캐시 확인
60
+ const cached = imageCache.get(cacheKey);
61
+ if (cached) {
62
+ return new Response(cached.data as unknown as BodyInit, {
63
+ headers: {
64
+ "Content-Type": cached.contentType,
65
+ "Cache-Control": "public, max-age=31536000, immutable",
66
+ "Vary": "Accept",
67
+ "X-Mandu-Image-Cache": "HIT",
68
+ },
69
+ });
70
+ }
71
+
72
+ // 원본 파일 경로 해석 + symlink traversal 방지
73
+ const allowedBaseDir = path.resolve(rootDir, publicDir);
74
+ const filePath = path.join(allowedBaseDir, src.slice(1));
75
+
76
+ // realpath로 symlink를 해석한 후 allowedBaseDir 내부인지 검증
77
+ let resolvedPath: string;
78
+ try {
79
+ const realFs = require("fs") as typeof import("fs");
80
+ resolvedPath = realFs.realpathSync(filePath);
81
+ const resolvedBase = realFs.realpathSync(allowedBaseDir);
82
+ if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) {
83
+ return new Response("Forbidden", { status: 403 });
84
+ }
85
+ } catch {
86
+ // realpath 실패 (broken symlink, 파일 없음) → 안전하게 404 반환
87
+ return new Response("Image not found", { status: 404 });
88
+ }
89
+ const file = Bun.file(resolvedPath);
90
+
91
+ if (!await file.exists()) {
92
+ return new Response("Image not found", { status: 404 });
93
+ }
94
+
95
+ try {
96
+ const original = await file.arrayBuffer();
97
+ const processed = await processImage(new Uint8Array(original), {
98
+ width,
99
+ quality,
100
+ format,
101
+ sourceContentType: getMimeForExtension(src),
102
+ });
103
+
104
+ // 캐시 저장 (LRU)
105
+ if (imageCache.size >= MAX_IMAGE_CACHE) {
106
+ const oldest = imageCache.keys().next().value;
107
+ if (oldest !== undefined) imageCache.delete(oldest);
108
+ }
109
+ imageCache.set(cacheKey, {
110
+ data: processed.data,
111
+ contentType: processed.contentType,
112
+ });
113
+
114
+ return new Response(processed.data as unknown as BodyInit, {
115
+ headers: {
116
+ "Content-Type": processed.contentType,
117
+ "Cache-Control": "public, max-age=31536000, immutable",
118
+ "Vary": "Accept",
119
+ },
120
+ });
121
+ } catch (error) {
122
+ console.error(`[Mandu Image] Processing failed for ${src}:`, error);
123
+ // 원본 파일 그대로 반환 (fallback)
124
+ return new Response(file, {
125
+ headers: {
126
+ "Content-Type": getMimeForExtension(src),
127
+ "Cache-Control": "public, max-age=86400",
128
+ },
129
+ });
130
+ }
131
+ }
132
+
133
+ // ========== Format Negotiation ==========
134
+
135
+ function negotiateFormat(request: Request): "webp" | "jpeg" | "png" | "avif" {
136
+ const accept = request.headers.get("Accept") ?? "";
137
+ if (accept.includes("image/avif")) return "avif";
138
+ if (accept.includes("image/webp")) return "webp";
139
+ return "jpeg";
140
+ }
141
+
142
+ function getMimeForExtension(src: string): string {
143
+ const ext = path.extname(src).toLowerCase();
144
+ const map: Record<string, string> = {
145
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
146
+ ".png": "image/png", ".gif": "image/gif",
147
+ ".webp": "image/webp", ".avif": "image/avif",
148
+ ".svg": "image/svg+xml",
149
+ };
150
+ return map[ext] ?? "application/octet-stream";
151
+ }
152
+
153
+ // ========== Image Processing ==========
154
+
155
+ /**
156
+ * 이미지 리사이즈 + 포맷 변환
157
+ * Bun의 내장 sharp 미지원 시 원본 반환 (graceful degradation)
158
+ */
159
+ async function processImage(
160
+ data: Uint8Array,
161
+ options: ImageOptions
162
+ ): Promise<ProcessedImageResult> {
163
+ // sharp 사용 시도 (선택적 의존성)
164
+ try {
165
+ const sharp = require("sharp") as any;
166
+ let pipeline = sharp(Buffer.from(data)).resize(options.width);
167
+
168
+ switch (options.format) {
169
+ case "webp":
170
+ pipeline = pipeline.webp({ quality: options.quality });
171
+ break;
172
+ case "avif":
173
+ pipeline = pipeline.avif({ quality: options.quality });
174
+ break;
175
+ case "jpeg":
176
+ pipeline = pipeline.jpeg({ quality: options.quality });
177
+ break;
178
+ case "png":
179
+ pipeline = pipeline.png({ quality: options.quality });
180
+ break;
181
+ }
182
+
183
+ const result = await pipeline.toBuffer();
184
+ return {
185
+ data: new Uint8Array(result),
186
+ contentType: `image/${options.format}`,
187
+ };
188
+ } catch {
189
+ // sharp 미설치 시 원본 반환
190
+ return {
191
+ data,
192
+ contentType: options.sourceContentType ?? "application/octet-stream",
193
+ };
194
+ }
195
+ }
@@ -10,3 +10,15 @@ export * from "./trace";
10
10
  export * from "./logger";
11
11
  export * from "./boundary";
12
12
  export * from "./stable-selector";
13
+ export {
14
+ revalidatePath,
15
+ revalidateTag,
16
+ getCacheStoreStats,
17
+ type CacheStore,
18
+ type CacheStoreStats,
19
+ MemoryCacheStore,
20
+ } from "./cache";
21
+ export { type MiddlewareContext, type MiddlewareNext, type MiddlewareFn, type MiddlewareConfig } from "./middleware";
22
+ export { type ManduAdapter, type AdapterOptions, type AdapterServer } from "./adapter";
23
+ export { adapterBun } from "./adapter-bun";
24
+ export { createFetchHandler, type FetchHandlerOptions } from "./handler";