perf: reduce oss image request costs

This commit is contained in:
Your Name 2026-05-28 21:40:46 +08:00
parent 033b3c298d
commit fc04267f74
3 changed files with 144 additions and 33 deletions

View File

@ -194,6 +194,7 @@ export async function POST(request: Request) {
Bucket: bucket,
Key: key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read",
});
const url = await getSignedUrl(client, command, {
@ -206,6 +207,7 @@ export async function POST(request: Request) {
url,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"x-amz-acl": "public-read",
},
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
@ -217,6 +219,7 @@ export async function POST(request: Request) {
Bucket: bucket,
Key: key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read",
})
);

View File

@ -9,8 +9,19 @@ const MIN_WIDTH = 64;
const MIN_QUALITY = 40;
const MAX_QUALITY = 100;
const FETCH_TIMEOUT_MS = 15000;
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
type OutputFormat = "webp" | "avif" | "jpeg" | "png";
type CachedImageProxyResponse = {
body: Buffer;
contentType: string;
etag: string;
vary?: string;
};
const memoryCache = new Map<string, CachedImageProxyResponse>();
let memoryCacheBytes = 0;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
@ -22,6 +33,25 @@ function parsePositiveInt(value: string | null): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getEnvPositiveInt(name: string, fallback: number) {
const parsed = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getMemoryCacheMaxBytes() {
return getEnvPositiveInt(
"IMAGE_PROXY_MEMORY_CACHE_MAX_BYTES",
DEFAULT_MEMORY_CACHE_MAX_BYTES
);
}
function getMemoryCacheMaxItems() {
return getEnvPositiveInt(
"IMAGE_PROXY_MEMORY_CACHE_MAX_ITEMS",
DEFAULT_MEMORY_CACHE_MAX_ITEMS
);
}
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
@ -90,6 +120,68 @@ function buildEtag(input: string): string {
return `"${crypto.createHash("sha1").update(input).digest("hex")}"`;
}
function buildImageResponse(
body: Buffer,
params: {
contentType: string;
etag: string;
vary?: string;
cacheStatus?: "HIT" | "MISS";
}
) {
const headers: Record<string, string> = {
"Content-Type": params.contentType,
"Content-Length": String(body.length),
ETag: params.etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
};
if (params.vary) {
headers.Vary = params.vary;
}
if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
}
return new NextResponse(body, {
status: 200,
headers,
});
}
function getCachedImageResponse(cacheKey: string) {
const cached = memoryCache.get(cacheKey);
if (!cached) return null;
memoryCache.delete(cacheKey);
memoryCache.set(cacheKey, cached);
return cached;
}
function setCachedImageResponse(cacheKey: string, cached: CachedImageProxyResponse) {
const maxBytes = getMemoryCacheMaxBytes();
const maxItems = getMemoryCacheMaxItems();
if (maxBytes <= 0 || maxItems <= 0 || cached.body.length > maxBytes) {
return;
}
const existing = memoryCache.get(cacheKey);
if (existing) {
memoryCacheBytes -= existing.body.length;
memoryCache.delete(cacheKey);
}
memoryCache.set(cacheKey, cached);
memoryCacheBytes += cached.body.length;
while (memoryCache.size > maxItems || memoryCacheBytes > maxBytes) {
const firstKey = memoryCache.keys().next().value;
if (!firstKey) break;
const removed = memoryCache.get(firstKey);
memoryCache.delete(firstKey);
if (removed) {
memoryCacheBytes -= removed.body.length;
}
}
}
function isBrowserImageRequest(request: NextRequest): boolean {
return request.headers.get("sec-fetch-dest") === "image";
}
@ -220,6 +312,16 @@ export async function GET(request: NextRequest) {
});
}
const cached = getCachedImageResponse(cacheKey);
if (cached) {
return buildImageResponse(cached.body, {
contentType: cached.contentType,
etag: cached.etag,
vary: cached.vary,
cacheStatus: "HIT",
});
}
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(target.toString(), {
@ -250,26 +352,28 @@ export async function GET(request: NextRequest) {
const isGif = contentType.toLowerCase().includes("image/gif");
if ((isSvg || isGif) && !width && !requestedFormat) {
return new NextResponse(originalBuffer, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(originalBuffer.length),
ETag: etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
},
setCachedImageResponse(cacheKey, {
body: originalBuffer,
contentType,
etag,
});
return buildImageResponse(originalBuffer, {
contentType,
etag,
cacheStatus: "MISS",
});
}
if (isSvg || isGif) {
return new NextResponse(originalBuffer, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(originalBuffer.length),
ETag: etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
},
setCachedImageResponse(cacheKey, {
body: originalBuffer,
contentType,
etag,
});
return buildImageResponse(originalBuffer, {
contentType,
etag,
cacheStatus: "MISS",
});
}
@ -303,26 +407,29 @@ export async function GET(request: NextRequest) {
outputContentType = "image/jpeg";
}
return new NextResponse(outputBuffer, {
status: 200,
headers: {
"Content-Type": outputContentType,
"Content-Length": String(outputBuffer.length),
ETag: etag,
Vary: "Accept",
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
},
setCachedImageResponse(cacheKey, {
body: outputBuffer,
contentType: outputContentType,
etag,
vary: "Accept",
});
return buildImageResponse(outputBuffer, {
contentType: outputContentType,
etag,
vary: "Accept",
cacheStatus: "MISS",
});
} catch (error) {
console.error("[ImageProxy] Transform failed:", error);
return new NextResponse(originalBuffer, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(originalBuffer.length),
ETag: etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
},
setCachedImageResponse(cacheKey, {
body: originalBuffer,
contentType,
etag,
});
return buildImageResponse(originalBuffer, {
contentType,
etag,
cacheStatus: "MISS",
});
}
}

View File

@ -219,6 +219,7 @@ async function uploadToS3(
Key: key,
Body: Buffer.from(buffer),
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
// 设置为公开可读(如果您的 MinIO 桶支持)
ACL: "public-read",
});