From ee65c6263ade12c360d63ce255eb82cc0ce8ffcc Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sun, 22 Mar 2026 06:52:47 +0100 Subject: [PATCH] perf: add ETag and server-side caching for thumbnail proxy Add ETag header to API thumbnail responses for 304 Not Modified support. Forward If-None-Match/ETag through the Next.js proxy route handler and add next.revalidate for 24h server-side fetch caching to reduce SSR-to-API round trips on the libraries page. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/books.rs | 5 +++ .../app/api/books/[bookId]/thumbnail/route.ts | 33 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 22960ff..93d4c1e 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -631,12 +631,17 @@ pub async fn get_thumbnail( crate::pages::render_book_page_1(&state, book_id, 300, 80).await? }; + let etag_value = format!("\"{}_{:x}\"", book_id, data.len()); + let mut headers = HeaderMap::new(); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); headers.insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"), ); + if let Ok(v) = HeaderValue::from_str(&etag_value) { + headers.insert(header::ETAG, v); + } Ok((StatusCode::OK, headers, Body::from(data))) } diff --git a/apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts b/apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts index 55e6185..b5b0863 100644 --- a/apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts +++ b/apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts @@ -9,10 +9,25 @@ export async function GET( try { const { baseUrl, token } = config(); + const ifNoneMatch = request.headers.get("if-none-match"); + + const fetchHeaders: Record = { + Authorization: `Bearer ${token}`, + }; + if (ifNoneMatch) { + fetchHeaders["If-None-Match"] = ifNoneMatch; + } + const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, { - headers: { Authorization: `Bearer ${token}` }, + headers: fetchHeaders, + next: { revalidate: 86400 }, }); + // Forward 304 Not Modified as-is + if (response.status === 304) { + return new NextResponse(null, { status: 304 }); + } + if (!response.ok) { return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, { status: response.status @@ -20,13 +35,17 @@ export async function GET( } const contentType = response.headers.get("content-type") || "image/webp"; + const etag = response.headers.get("etag"); - return new NextResponse(response.body, { - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=31536000, immutable", - }, - }); + const headers: Record = { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }; + if (etag) { + headers["ETag"] = etag; + } + + return new NextResponse(response.body, { headers }); } catch (error) { console.error("Error fetching thumbnail:", error); return new NextResponse("Failed to fetch thumbnail", { status: 500 });