Conversation
Add image optimization utility that resizes ENS avatars from 704×704 to 96×96 and converts to WebP format, reducing file sizes by ~95%. Includes graceful fallback to original image on optimization failure. Extracted from #509. Co-Authored-By: Sebastian <115311276+Roaring30s@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR introduces server-side ENS avatar image optimization so the Explorer can serve smaller, faster-loading avatar images via the existing /api/ens-data/image/[name] endpoint.
Changes:
- Add a reusable Sharp-based
optimizeImageutility for resizing/converting images to WebP. - Integrate the optimizer into the ENS avatar image API route (targeting 96×96 WebP).
- Add the
sharpdependency (and lockfile updates).
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
lib/api/image-optimization.ts |
Adds a Sharp-powered image optimization helper returning optimized bytes + metadata. |
pages/api/ens-data/image/[name].tsx |
Uses optimizeImage to serve optimized ENS avatars from the API endpoint. |
package.json |
Adds sharp as a dependency. |
pnpm-lock.yaml |
Locks sharp and related transitive dependencies. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Set appropriate content type (fallback to original if optimization failed) | ||
| if (optimizationResult.contentType === "image/jpeg") { | ||
| const originalContentType = | ||
| response.headers.get("content-type") || "image/jpeg"; | ||
| res.setHeader("Content-Type", originalContentType); | ||
| } else { | ||
| res.setHeader("Content-Type", optimizationResult.contentType); | ||
| } |
There was a problem hiding this comment.
Using optimizationResult.contentType === "image/jpeg" as a sentinel for "optimization failed" is brittle and couples the route to the utility's implementation details. Consider returning an explicit flag (e.g., optimized: boolean) and/or originalContentType from optimizeImage so the caller can set headers without relying on a magic content type.
| // Set appropriate content type (fallback to original if optimization failed) | |
| if (optimizationResult.contentType === "image/jpeg") { | |
| const originalContentType = | |
| response.headers.get("content-type") || "image/jpeg"; | |
| res.setHeader("Content-Type", originalContentType); | |
| } else { | |
| res.setHeader("Content-Type", optimizationResult.contentType); | |
| } | |
| // Set appropriate content type, preferring optimized result and falling back to original if needed | |
| const resolvedContentType = | |
| optimizationResult.contentType || | |
| response.headers.get("content-type") || | |
| "image/jpeg"; | |
| res.setHeader("Content-Type", resolvedContentType); |
| const { width = 96, height = 96, quality = 75, effort = 6 } = options; | ||
|
|
||
| const originalSize = imageBuffer.byteLength; | ||
| const originalBuffer = Buffer.from(imageBuffer); | ||
|
|
There was a problem hiding this comment.
optimizeImage processes untrusted, externally-fetched image bytes with Sharp without any explicit limits. Consider adding guardrails (max input byte size and/or limitInputPixels) to reduce risk of decompression bombs / CPU+memory DoS, and skip optimization (or reject) when limits are exceeded.
| // Get original image metadata | ||
| let originalMetadata; | ||
| try { | ||
| originalMetadata = await sharp(originalBuffer).metadata(); | ||
| } catch { |
There was a problem hiding this comment.
sharp(originalBuffer).metadata() is called and then the image is decoded again during the resize/WebP conversion. This doubles decoding work for every request. Consider reusing a single Sharp pipeline (or making original dimension capture optional) to avoid the extra decode.
| return { | ||
| buffer: originalBuffer, | ||
| contentType: "image/jpeg", // Default fallback | ||
| originalSize, |
There was a problem hiding this comment.
In the fallback path, contentType is hard-coded to image/jpeg. If this utility is reused elsewhere, callers may inadvertently serve non-JPEG bytes with the wrong Content-Type. Prefer returning contentType: null/undefined on fallback (forcing the caller to supply the original), or accept the original content-type as an input and echo it back when optimization fails.
| }; | ||
| } catch { | ||
| // Fallback to original image if optimization fails | ||
| // This is expected for some edge cases (unsupported formats, corrupted images, etc. |
There was a problem hiding this comment.
Comment typo: the parenthesis is unclosed in “(unsupported formats, corrupted images, etc.”.
| // This is expected for some edge cases (unsupported formats, corrupted images, etc. | |
| // This is expected for some edge cases (unsupported formats, corrupted images, etc.) |
| @@ -48,9 +49,30 @@ const handler = async ( | |||
|
|
|||
There was a problem hiding this comment.
fetch(imageUrl) is followed by unconditional response.arrayBuffer() and optimization. If the upstream returns a 404/500 or a non-image content-type (e.g. text/html), this route will still return 200 with that body (and may attempt Sharp decoding). Add a response.ok check and validate Content-Type starts with image/ before reading/processing; otherwise return notFound/externalApiError appropriately.
| // Ensure the upstream request succeeded | |
| if (!response.ok) { | |
| if (response.status === 404) { | |
| return notFound(res, "ENS avatar not found"); | |
| } | |
| return internalError( | |
| res, | |
| new Error( | |
| `Failed to fetch ENS avatar: ${response.status} ${response.statusText}` | |
| ) | |
| ); | |
| } | |
| // Ensure the upstream response is an image | |
| const upstreamContentType = | |
| response.headers.get("content-type")?.toLowerCase() || ""; | |
| if (!upstreamContentType.startsWith("image/")) { | |
| return notFound(res, "ENS avatar not found"); | |
| } |
| const optimizationResult = await optimizeImage(arrayBuffer, { | ||
| width: 96, | ||
| height: 96, | ||
| quality: 75, | ||
| effort: 6, |
There was a problem hiding this comment.
This endpoint now always returns WebP on success. To avoid breaking clients/crawlers that don't send Accept: image/webp, consider negotiating based on the request Accept header (and returning the original bytes otherwise), and set Vary: Accept when the response format can change.
There was a problem hiding this comment.
Additional Suggestions:
- ENS blacklist check is case-sensitive, allowing attackers to bypass it using different case variations of blacklisted names
- Fallback ENS avatar URL uses non-normalized name instead of normalized version, causing inconsistency with the primary ENS API call and potential lookup failures on the metadata service
| * @returns Optimized image buffer and metadata, or original buffer if optimization fails | ||
| */ | ||
| export async function optimizeImage( | ||
| imageBuffer: ArrayBuffer, |
| : undefined, | ||
| format: optimizedMetadata?.format, | ||
| }; | ||
| } catch { |
|
|
||
| const arrayBuffer = await response.arrayBuffer(); | ||
|
|
||
| // Optimize image using utility |
Summary
optimizeImageutility inlib/api/image-optimization.ts/api/ens-data/image/[name]endpointsharpdependencyExtracted from #509 by @Roaring30s — Lighthouse performance improvements.
Partially addresses #433.
Test plan
🤖 Generated with Claude Code