@mandujs/core 0.41.2 → 0.43.0

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 (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,128 +1,126 @@
1
- /**
2
- * Phase 7.1 — Fast Refresh shared types
3
- *
4
- * Bun 1.3.12 ships a native `Bun.build({ reactFastRefresh: true })` flag
5
- * that performs the `$RefreshReg$` / `$RefreshSig$` source transform
6
- * (equivalent to `babel-plugin-react-refresh`). Agent B's implementation
7
- * wires this into Mandu's bundler + a browser-side runtime that glues
8
- * `react-refresh/runtime` to our existing `ManduHot` dispatcher from
9
- * Phase 7.0.C.
10
- *
11
- * This file is the CONTRACT. Types only — the implementation lives in:
12
- * - packages/core/src/bundler/fast-refresh-plugin.ts (Agent B new)
13
- * - packages/core/src/runtime/fast-refresh-runtime.ts (Agent B new)
14
- * - packages/core/src/bundler/build.ts (Agent B extend — vendor shim)
15
- * - packages/core/src/bundler/dev.ts (Agent B extend — HTML preamble)
16
- *
17
- * References:
18
- * docs/bun/phase-7-1-diagnostics/fast-refresh-strategy.md
19
- * docs/bun/phase-7-1-team-plan.md §3.2
20
- * https://bun.com/reference/bun/NormalBuildConfig/reactFastRefresh
21
- */
22
-
23
- // ============================================
24
- // Browser-side `__MANDU_HMR__` global
25
- // ============================================
26
-
27
- /**
28
- * The single global object Mandu's bundler-emitted boundary code talks
29
- * to. Installed by the HTML preamble script before any island runs.
30
- *
31
- * Keeping all Fast Refresh wiring behind this single namespace makes
32
- * the attack surface (Phase 7.1.E security audit) a single module.
33
- */
34
- export interface ManduHMRGlobal {
35
- /**
36
- * Register a module URL as an HMR boundary. Emitted by the bundler
37
- * onLoad plugin (Agent B) for each `.client.tsx` / `.island.tsx`.
38
- * Idempotent — registering the same URL twice is a no-op.
39
- */
40
- acceptFile(moduleUrl: string): void;
41
-
42
- /**
43
- * Invoke `react-refresh/runtime.performReactRefresh()` on the next
44
- * microtask. Coalesces multiple calls within the same tick into a
45
- * single refresh, so a batched rebuild that swaps 3 modules only
46
- * re-renders the React tree once.
47
- */
48
- performReactRefresh(): void;
49
-
50
- /**
51
- * Whether `moduleUrl` was ever registered via `acceptFile`. Used by
52
- * `dispatchReplacement` (Phase 7.0.C runtime/hmr-client.ts) to decide
53
- * between "call performReactRefresh" vs "full reload fallback".
54
- */
55
- isBoundary(moduleUrl: string): boolean;
56
-
57
- /**
58
- * Reset state — test-only. Clears the boundary registry and React
59
- * refresh runtime scheduling state. Must NOT be exposed in production
60
- * bundles; tests import this via the `runtime/fast-refresh-runtime.ts`
61
- * module directly.
62
- */
63
- _testOnly_reset(): void;
64
- }
65
-
66
- /**
67
- * Metadata the bundler attaches to each emitted boundary. Not part of
68
- * the runtime API — consumed only by dev-mode tooling (hmr-bench,
69
- * Kitchen DevTools) and by the security audit (Phase 7.1.E) to verify
70
- * that every injected boundary traces back to a real source file.
71
- */
72
- export interface RefreshBoundaryMetadata {
73
- /** URL the module is served under (e.g. `/.mandu/client/home.island.js`). */
74
- moduleUrl: string;
75
- /** Source files that were bundled into this module, from Bun.build's
76
- * inline sourcemap `sources[]` (same mechanism Phase 7.0.B uses). */
77
- sources: readonly string[];
78
- /** Unix ms when the boundary was registered — for debug log ordering. */
79
- registeredAt: number;
80
- }
81
-
82
- // ============================================
83
- // Bundler side
84
- // ============================================
85
-
86
- /**
87
- * Options passed to the Fast Refresh Bun.build plugin (Agent B new file
88
- * `bundler/fast-refresh-plugin.ts`). All optional — sensible defaults
89
- * match Vite's `@vitejs/plugin-react` behavior.
90
- */
91
- export interface FastRefreshPluginOptions {
92
- /** Only transform files matching this test. Default: `/\.(client|island)\.tsx?$/`. */
93
- include?: RegExp;
94
- /** Bypass the transform entirely. Used for prod builds. Default: `false`. */
95
- disabled?: boolean;
96
- /**
97
- * Runtime module specifier the plugin's injected import points at.
98
- * Default: `"@mandujs/core/runtime/fast-refresh-runtime"`. Tests can
99
- * override to a tmpdir-local stub.
100
- */
101
- runtimeImport?: string;
102
- }
103
-
104
- /**
105
- * Classification of why a module was (or wasn't) made a Fast Refresh
106
- * boundary. Used by the plugin's debug log + Agent D's E2E assertions.
107
- */
108
- export type BoundaryDecision =
109
- | { accepted: true; reason: "matched-include"; source: string }
110
- | { accepted: false; reason: "excluded-by-include" | "disabled" | "non-react" };
111
-
112
- // ============================================
113
- // Global augmentation — Window type
114
- // ============================================
115
-
116
- /**
117
- * Expose `__MANDU_HMR__` on the browser Window without polluting Node
118
- * typings. Users of the plugin see `window.__MANDU_HMR__.acceptFile(...)`
119
- * as strongly typed in TSX files.
120
- */
121
- declare global {
122
- interface Window {
123
- /** Phase 7.1 Fast Refresh glue. Installed by HTML preamble. */
124
- __MANDU_HMR__?: ManduHMRGlobal;
125
- }
126
- }
127
-
128
- export {}; // ensure this file is a module for `declare global`
1
+ /**
2
+ * Phase 7.1 — Fast Refresh shared types
3
+ *
4
+ * Bun 1.3.12 ships a native `Bun.build({ reactFastRefresh: true })` flag
5
+ * that performs the `$RefreshReg$` / `$RefreshSig$` source transform
6
+ * (equivalent to `babel-plugin-react-refresh`). Agent B's implementation
7
+ * wires this into Mandu's bundler + a browser-side runtime that glues
8
+ * `react-refresh/runtime` to our existing `ManduHot` dispatcher from
9
+ * Phase 7.0.C.
10
+ *
11
+ * This file is the CONTRACT. Types only — the implementation lives in:
12
+ * - packages/core/src/bundler/fast-refresh-plugin.ts (Agent B new)
13
+ * - packages/core/src/runtime/fast-refresh-runtime.ts (Agent B new)
14
+ * - packages/core/src/bundler/build.ts (Agent B extend — vendor shim)
15
+ * - packages/core/src/bundler/dev.ts (Agent B extend — HTML preamble)
16
+ *
17
+ * References:
18
+ * docs/bun/phase-7-1-diagnostics/fast-refresh-strategy.md
19
+ * docs/bun/phase-7-1-team-plan.md §3.2
20
+ * https://bun.com/reference/bun/NormalBuildConfig/reactFastRefresh
21
+ */
22
+
23
+ // ============================================
24
+ // Browser-side `__MANDU_HMR__` global
25
+ // ============================================
26
+
27
+ /**
28
+ * The single global object Mandu's bundler-emitted boundary code talks
29
+ * to. Installed by the HTML preamble script before any island runs.
30
+ *
31
+ * Keeping all Fast Refresh wiring behind this single namespace makes
32
+ * the attack surface (Phase 7.1.E security audit) a single module.
33
+ */
34
+ export interface ManduHMRGlobal {
35
+ /**
36
+ * Register a module URL as an HMR boundary. Emitted by the bundler
37
+ * onLoad plugin (Agent B) for each `.client.tsx` / `.island.tsx`.
38
+ * Idempotent — registering the same URL twice is a no-op.
39
+ */
40
+ acceptFile(moduleUrl: string): void;
41
+
42
+ /**
43
+ * Invoke `react-refresh/runtime.performReactRefresh()` on the next
44
+ * microtask. Coalesces multiple calls within the same tick into a
45
+ * single refresh, so a batched rebuild that swaps 3 modules only
46
+ * re-renders the React tree once.
47
+ */
48
+ performReactRefresh(): void;
49
+
50
+ /**
51
+ * Whether `moduleUrl` was ever registered via `acceptFile`. Used by
52
+ * `dispatchReplacement` (Phase 7.0.C runtime/hmr-client.ts) to decide
53
+ * between "call performReactRefresh" vs "full reload fallback".
54
+ */
55
+ isBoundary(moduleUrl: string): boolean;
56
+
57
+ /**
58
+ * Reset state — test-only. Clears the boundary registry and React
59
+ * refresh runtime scheduling state. Must NOT be exposed in production
60
+ * bundles; tests import this via the `runtime/fast-refresh-runtime.ts`
61
+ * module directly.
62
+ */
63
+ _testOnly_reset(): void;
64
+ }
65
+
66
+ /**
67
+ * Metadata the bundler attaches to each emitted boundary. Not part of
68
+ * the runtime API — consumed only by dev-mode tooling (hmr-bench,
69
+ * Kitchen DevTools) and by the security audit (Phase 7.1.E) to verify
70
+ * that every injected boundary traces back to a real source file.
71
+ */
72
+ export interface RefreshBoundaryMetadata {
73
+ /** URL the module is served under (e.g. `/.mandu/client/home.island.js`). */
74
+ moduleUrl: string;
75
+ /** Source files that were bundled into this module, from Bun.build's
76
+ * inline sourcemap `sources[]` (same mechanism Phase 7.0.B uses). */
77
+ sources: readonly string[];
78
+ /** Unix ms when the boundary was registered — for debug log ordering. */
79
+ registeredAt: number;
80
+ }
81
+
82
+ // ============================================
83
+ // Bundler side
84
+ // ============================================
85
+
86
+ /**
87
+ * Options passed to the Fast Refresh Bun.build plugin (Agent B new file
88
+ * `bundler/fast-refresh-plugin.ts`). All optional — sensible defaults
89
+ * match Vite's `@vitejs/plugin-react` behavior.
90
+ */
91
+ export interface FastRefreshPluginOptions {
92
+ /** Only transform files matching this test. Default: `/\.(client|island)\.tsx?$/`. */
93
+ include?: RegExp;
94
+ /** Bypass the transform entirely. Used for prod builds. Default: `false`. */
95
+ disabled?: boolean;
96
+ /**
97
+ * Runtime module specifier the plugin's injected import points at.
98
+ * Default: `"@mandujs/core/runtime/fast-refresh-runtime"`. Tests can
99
+ * override to a tmpdir-local stub.
100
+ */
101
+ runtimeImport?: string;
102
+ }
103
+
104
+ /**
105
+ * Classification of why a module was (or wasn't) made a Fast Refresh
106
+ * boundary. Used by the plugin's debug log + Agent D's E2E assertions.
107
+ */
108
+ export type BoundaryDecision =
109
+ | { accepted: true; reason: "matched-include"; source: string }
110
+ | { accepted: false; reason: "excluded-by-include" | "disabled" | "non-react" };
111
+
112
+ // ============================================
113
+ // Global augmentation — Window type
114
+ // ============================================
115
+
116
+ /**
117
+ * Expose `__MANDU_HMR__` on the browser Window without polluting Node
118
+ * typings. Users of the plugin see `window.__MANDU_HMR__.acceptFile(...)`
119
+ * as strongly typed in TSX files.
120
+ */
121
+ declare global {
122
+ interface Window {
123
+ /** Phase 7.1 Fast Refresh glue. Installed by HTML preamble. */
124
+ __MANDU_HMR__?: ManduHMRGlobal;
125
+ }
126
+ }
@@ -1,195 +1,206 @@
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
- }
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
+ // sharp is a heavyweight optional native dep. Type it minimally to the
166
+ // tiny surface we actually call — `resize/webp/avif/jpeg/png/toBuffer`.
167
+ interface SharpPipeline {
168
+ resize(width: number): SharpPipeline;
169
+ webp(opts: { quality: number }): SharpPipeline;
170
+ avif(opts: { quality: number }): SharpPipeline;
171
+ jpeg(opts: { quality: number }): SharpPipeline;
172
+ png(opts: { quality: number }): SharpPipeline;
173
+ toBuffer(): Promise<Buffer>;
174
+ }
175
+ type SharpFn = (input: Buffer) => SharpPipeline;
176
+ const sharp = require("sharp") as SharpFn;
177
+ let pipeline = sharp(Buffer.from(data)).resize(options.width);
178
+
179
+ switch (options.format) {
180
+ case "webp":
181
+ pipeline = pipeline.webp({ quality: options.quality });
182
+ break;
183
+ case "avif":
184
+ pipeline = pipeline.avif({ quality: options.quality });
185
+ break;
186
+ case "jpeg":
187
+ pipeline = pipeline.jpeg({ quality: options.quality });
188
+ break;
189
+ case "png":
190
+ pipeline = pipeline.png({ quality: options.quality });
191
+ break;
192
+ }
193
+
194
+ const result = await pipeline.toBuffer();
195
+ return {
196
+ data: new Uint8Array(result),
197
+ contentType: `image/${options.format}`,
198
+ };
199
+ } catch {
200
+ // sharp 미설치 시 원본 반환
201
+ return {
202
+ data,
203
+ contentType: options.sourceContentType ?? "application/octet-stream",
204
+ };
205
+ }
206
+ }