Files
EpicNext-Cms/src/app/imaging/route.ts
T
openhands 1df1ffc3e9
CI / check (push) Failing after 24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
Make avatar imager resilient with upstream fallback everywhere
2026-09-14 17:35:36 +02:00

93 lines
2.6 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
type AvatarFormat = "png" | "webp" | "avif";
function resolveFormat(raw: string | null): AvatarFormat {
switch (raw?.toLowerCase()) {
case "webp":
return "webp";
case "avif":
return "avif";
default:
return "png";
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const parsed = parseAvatarQuery(searchParams);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.message }, { status: 400 });
}
const format = resolveFormat(searchParams.get("format"));
try {
const { body, contentType, source } = await fetchAvatarImage(
new URL(request.url).origin,
parsed.params,
);
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
// Falls back to the original PNG if conversion fails.
let respBody: Uint8Array = body;
let respContentType = contentType;
if (format !== "png") {
try {
if (format === "webp") {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).webp().toBuffer(),
);
respContentType = "image/webp";
} else {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).avif().toBuffer(),
);
respContentType = "image/avif";
}
} catch {
respBody = body;
respContentType = contentType;
}
}
return new NextResponse(respBody as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": respContentType,
"Content-Length": String(respBody.length),
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
"Cache-Control":
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch {
return NextResponse.json(
{ error: "Failed to fetch avatar from upstream" },
{ status: 502 },
);
}
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
},
});
}