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,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
ACL: "public-read",
|
ACL: "public-read",
|
||||||
});
|
});
|
||||||
const url = await getSignedUrl(client, command, {
|
const url = await getSignedUrl(client, command, {
|
||||||
@ -206,6 +207,7 @@ export async function POST(request: Request) {
|
|||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
"x-amz-acl": "public-read",
|
"x-amz-acl": "public-read",
|
||||||
},
|
},
|
||||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||||
@ -217,6 +219,7 @@ export async function POST(request: Request) {
|
|||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
ACL: "public-read",
|
ACL: "public-read",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,8 +9,19 @@ const MIN_WIDTH = 64;
|
|||||||
const MIN_QUALITY = 40;
|
const MIN_QUALITY = 40;
|
||||||
const MAX_QUALITY = 100;
|
const MAX_QUALITY = 100;
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
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 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 {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.max(min, Math.min(max, value));
|
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;
|
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 {
|
function isPrivateIpv4(hostname: string): boolean {
|
||||||
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||||
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
|
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")}"`;
|
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 {
|
function isBrowserImageRequest(request: NextRequest): boolean {
|
||||||
return request.headers.get("sec-fetch-dest") === "image";
|
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;
|
let upstreamResponse: Response;
|
||||||
try {
|
try {
|
||||||
upstreamResponse = await fetch(target.toString(), {
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
@ -250,26 +352,28 @@ export async function GET(request: NextRequest) {
|
|||||||
const isGif = contentType.toLowerCase().includes("image/gif");
|
const isGif = contentType.toLowerCase().includes("image/gif");
|
||||||
|
|
||||||
if ((isSvg || isGif) && !width && !requestedFormat) {
|
if ((isSvg || isGif) && !width && !requestedFormat) {
|
||||||
return new NextResponse(originalBuffer, {
|
setCachedImageResponse(cacheKey, {
|
||||||
status: 200,
|
body: originalBuffer,
|
||||||
headers: {
|
contentType,
|
||||||
"Content-Type": contentType,
|
etag,
|
||||||
"Content-Length": String(originalBuffer.length),
|
});
|
||||||
ETag: etag,
|
return buildImageResponse(originalBuffer, {
|
||||||
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
contentType,
|
||||||
},
|
etag,
|
||||||
|
cacheStatus: "MISS",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSvg || isGif) {
|
if (isSvg || isGif) {
|
||||||
return new NextResponse(originalBuffer, {
|
setCachedImageResponse(cacheKey, {
|
||||||
status: 200,
|
body: originalBuffer,
|
||||||
headers: {
|
contentType,
|
||||||
"Content-Type": contentType,
|
etag,
|
||||||
"Content-Length": String(originalBuffer.length),
|
});
|
||||||
ETag: etag,
|
return buildImageResponse(originalBuffer, {
|
||||||
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
contentType,
|
||||||
},
|
etag,
|
||||||
|
cacheStatus: "MISS",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,26 +407,29 @@ export async function GET(request: NextRequest) {
|
|||||||
outputContentType = "image/jpeg";
|
outputContentType = "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(outputBuffer, {
|
setCachedImageResponse(cacheKey, {
|
||||||
status: 200,
|
body: outputBuffer,
|
||||||
headers: {
|
contentType: outputContentType,
|
||||||
"Content-Type": outputContentType,
|
etag,
|
||||||
"Content-Length": String(outputBuffer.length),
|
vary: "Accept",
|
||||||
ETag: etag,
|
});
|
||||||
Vary: "Accept",
|
return buildImageResponse(outputBuffer, {
|
||||||
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
contentType: outputContentType,
|
||||||
},
|
etag,
|
||||||
|
vary: "Accept",
|
||||||
|
cacheStatus: "MISS",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ImageProxy] Transform failed:", error);
|
console.error("[ImageProxy] Transform failed:", error);
|
||||||
return new NextResponse(originalBuffer, {
|
setCachedImageResponse(cacheKey, {
|
||||||
status: 200,
|
body: originalBuffer,
|
||||||
headers: {
|
contentType,
|
||||||
"Content-Type": contentType,
|
etag,
|
||||||
"Content-Length": String(originalBuffer.length),
|
});
|
||||||
ETag: etag,
|
return buildImageResponse(originalBuffer, {
|
||||||
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
contentType,
|
||||||
},
|
etag,
|
||||||
|
cacheStatus: "MISS",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -219,6 +219,7 @@ async function uploadToS3(
|
|||||||
Key: key,
|
Key: key,
|
||||||
Body: Buffer.from(buffer),
|
Body: Buffer.from(buffer),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
// 设置为公开可读(如果您的 MinIO 桶支持)
|
// 设置为公开可读(如果您的 MinIO 桶支持)
|
||||||
ACL: "public-read",
|
ACL: "public-read",
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user