perf: reduce oss image request costs
This commit is contained in:
parent
033b3c298d
commit
fc04267f74
@ -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",
|
||||
})
|
||||
);
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user