@@ -3,6 +3,7 @@ import fs from "fs";
33import path from "path" ;
44import { fileURLToPath } from "url" ;
55import axios from "axios" ;
6+ import sharp from "sharp" ;
67
78const __filename = fileURLToPath ( import . meta. url ) ;
89const __dirname = path . dirname ( __filename ) ;
@@ -11,6 +12,10 @@ const IMAGE_PROXY_ROUTE = "/api/image-proxy";
1112const IMAGE_PROXY_DIR = path . join ( __dirname , ".." , "data" , "image-proxy" ) ;
1213const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000 ;
1314const FETCH_TIMEOUT_MS = 25000 ;
15+ const OPTIMIZED_IMAGE_MAX_BYTES = 1024 * 1024 ;
16+ const DEFAULT_WEBP_QUALITY = 70 ;
17+ const FALLBACK_WEBP_QUALITIES = [ 70 , 60 , 50 , 40 ] ;
18+ const FALLBACK_MAX_DIMENSIONS = [ null , 1600 , 1400 , 1200 , 1000 , 800 ] ;
1419const inflightRequests = new Map ( ) ;
1520const cacheEntriesByKey = new Map ( ) ;
1621const cacheKeysBySourceUrl = new Map ( ) ;
@@ -35,6 +40,12 @@ const MIME_EXTENSION_MAP = {
3540 "image/avif" : "avif" ,
3641 "image/svg+xml" : "svg" ,
3742} ;
43+ const OPTIMIZABLE_CONTENT_TYPES = new Set ( [
44+ "image/jpeg" ,
45+ "image/png" ,
46+ "image/webp" ,
47+ "image/avif" ,
48+ ] ) ;
3849
3950const ensureCacheDir = ( ) => {
4051 fs . mkdirSync ( IMAGE_PROXY_DIR , { recursive : true } ) ;
@@ -109,6 +120,18 @@ const getCachePaths = (cacheKey) => ({
109120 baseImagePath : path . join ( IMAGE_PROXY_DIR , `${ cacheKey } ` ) ,
110121} ) ;
111122
123+ const removeStaleCachedFiles = ( cacheKey , keepExtension ) => {
124+ ensureCacheDir ( ) ;
125+ const prefix = `${ cacheKey } .` ;
126+ for ( const file of fs . readdirSync ( IMAGE_PROXY_DIR ) ) {
127+ if ( ! file . startsWith ( prefix ) || file . endsWith ( ".json" ) ) continue ;
128+ if ( keepExtension && file === `${ cacheKey } .${ keepExtension } ` ) continue ;
129+ try {
130+ fs . unlinkSync ( path . join ( IMAGE_PROXY_DIR , file ) ) ;
131+ } catch { }
132+ }
133+ } ;
134+
112135const buildLocalImageUrl = ( cacheKey , extension ) =>
113136 `${ IMAGE_PROXY_ROUTE } /${ cacheKey } .${ extension } ` ;
114137
@@ -162,6 +185,7 @@ const writeCacheEntry = (cacheKey, buffer, contentType, sourceUrl) => {
162185 size : buffer . length ,
163186 } ;
164187
188+ removeStaleCachedFiles ( cacheKey , extension ) ;
165189 fs . writeFileSync ( imagePath , buffer ) ;
166190 fs . writeFileSync ( metaPath , JSON . stringify ( meta ) ) ;
167191 const entry = {
@@ -178,6 +202,122 @@ const writeCacheEntry = (cacheKey, buffer, contentType, sourceUrl) => {
178202 return entry ;
179203} ;
180204
205+ const shouldNormalizeCachedEntry = ( entry ) => {
206+ if ( ! entry ?. imagePath || ! entry ?. meta ?. contentType ) return false ;
207+ if ( ! OPTIMIZABLE_CONTENT_TYPES . has ( entry . meta . contentType ) ) return false ;
208+ return (
209+ entry . meta . contentType !== "image/webp" ||
210+ Number ( entry . meta . size || 0 ) > OPTIMIZED_IMAGE_MAX_BYTES
211+ ) ;
212+ } ;
213+
214+ const optimizeImageBuffer = async ( buffer , contentType ) => {
215+ if ( ! OPTIMIZABLE_CONTENT_TYPES . has ( contentType ) ) {
216+ return {
217+ buffer,
218+ contentType,
219+ } ;
220+ }
221+
222+ let metadata = null ;
223+ try {
224+ metadata = await sharp ( buffer , { animated : false } ) . metadata ( ) ;
225+ } catch {
226+ return {
227+ buffer,
228+ contentType,
229+ } ;
230+ }
231+
232+ const largestDimension = Math . max ( metadata ?. width || 0 , metadata ?. height || 0 ) ;
233+ const dimensionSteps = FALLBACK_MAX_DIMENSIONS . filter (
234+ ( dimension ) => dimension === null || largestDimension > dimension ,
235+ ) ;
236+ if ( dimensionSteps . length === 0 ) {
237+ dimensionSteps . push ( null ) ;
238+ }
239+
240+ let bestCandidate = null ;
241+
242+ for ( const maxDimension of dimensionSteps ) {
243+ for ( const quality of FALLBACK_WEBP_QUALITIES ) {
244+ let candidate = sharp ( buffer , { animated : false } ) . rotate ( ) ;
245+ if ( maxDimension ) {
246+ candidate = candidate . resize ( {
247+ width : maxDimension ,
248+ height : maxDimension ,
249+ fit : "inside" ,
250+ withoutEnlargement : true ,
251+ } ) ;
252+ }
253+
254+ const optimizedBuffer = await candidate
255+ . webp ( {
256+ quality,
257+ effort : 4 ,
258+ } )
259+ . toBuffer ( ) ;
260+
261+ if (
262+ ! bestCandidate ||
263+ optimizedBuffer . length < bestCandidate . buffer . length
264+ ) {
265+ bestCandidate = {
266+ buffer : optimizedBuffer ,
267+ contentType : "image/webp" ,
268+ } ;
269+ }
270+
271+ if (
272+ quality === DEFAULT_WEBP_QUALITY &&
273+ optimizedBuffer . length <= OPTIMIZED_IMAGE_MAX_BYTES
274+ ) {
275+ return {
276+ buffer : optimizedBuffer ,
277+ contentType : "image/webp" ,
278+ } ;
279+ }
280+
281+ if ( optimizedBuffer . length <= OPTIMIZED_IMAGE_MAX_BYTES ) {
282+ return {
283+ buffer : optimizedBuffer ,
284+ contentType : "image/webp" ,
285+ } ;
286+ }
287+ }
288+ }
289+
290+ return bestCandidate || { buffer, contentType } ;
291+ } ;
292+
293+ const normalizeCachedEntryIfNeeded = async ( entry ) => {
294+ if ( ! shouldNormalizeCachedEntry ( entry ) ) {
295+ return entry ;
296+ }
297+
298+ let buffer = null ;
299+ try {
300+ buffer = fs . readFileSync ( entry . imagePath ) ;
301+ } catch {
302+ return entry ;
303+ }
304+
305+ const optimized = await optimizeImageBuffer ( buffer , entry . meta . contentType ) ;
306+ if (
307+ optimized . contentType === entry . meta . contentType &&
308+ optimized . buffer . length === buffer . length
309+ ) {
310+ return entry ;
311+ }
312+
313+ return writeCacheEntry (
314+ entry . cacheKey ,
315+ optimized . buffer ,
316+ optimized . contentType ,
317+ normalizeKnownImageUrl ( entry . meta . sourceUrl ) || entry . meta . sourceUrl || null ,
318+ ) ;
319+ } ;
320+
181321const fetchAndCacheImage = async ( sourceUrl ) => {
182322 const normalizedSourceUrl = normalizeKnownImageUrl ( sourceUrl ) ;
183323 if ( ! normalizedSourceUrl ) {
@@ -210,10 +350,15 @@ const fetchAndCacheImage = async (sourceUrl) => {
210350 throw new Error ( "Upstream response is not an image" ) ;
211351 }
212352
213- return writeCacheEntry (
214- hashValue ( normalizedSourceUrl ) ,
353+ const optimized = await optimizeImageBuffer (
215354 Buffer . from ( response . data ) ,
216355 contentType ,
356+ ) ;
357+
358+ return writeCacheEntry (
359+ hashValue ( normalizedSourceUrl ) ,
360+ optimized . buffer ,
361+ optimized . contentType ,
217362 normalizedSourceUrl ,
218363 ) ;
219364} ;
@@ -223,7 +368,7 @@ export const warmImageProxy = async (sourceUrl) => {
223368 if ( cacheKeyFromLocalUrl ) {
224369 const cachedLocal = getCachedEntryFromKey ( cacheKeyFromLocalUrl ) ;
225370 if ( cachedLocal ?. imagePath ) {
226- return cachedLocal ;
371+ return normalizeCachedEntryIfNeeded ( cachedLocal ) ;
227372 }
228373 throw new Error ( "Missing local cached image" ) ;
229374 }
@@ -235,7 +380,7 @@ export const warmImageProxy = async (sourceUrl) => {
235380
236381 const cached = getCachedEntry ( normalizedSourceUrl ) ;
237382 if ( cached ?. isFresh ) {
238- return cached ;
383+ return normalizeCachedEntryIfNeeded ( cached ) ;
239384 }
240385
241386 if ( inflightRequests . has ( normalizedSourceUrl ) ) {
0 commit comments