@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- 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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|