11import type { LocalImageService , ImageTransform } from "astro" ;
2+ import * as fs from "fs/promises" ;
3+ import * as path from "path" ;
4+ import * as crypto from "crypto" ;
25
36type OutputFormat = "avif" | "jpeg" | "jpg" | "png" | "webp" ;
47
8+ const CACHE_DIR = path . join ( process . cwd ( ) , ".cache/images" ) ;
9+ const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 ;
10+ const FETCH_TIMEOUT = 5000 ;
11+ const MAX_RETRIES = 2 ;
12+
13+ async function ensureCacheDir ( ) : Promise < boolean > {
14+ try {
15+ await fs . mkdir ( CACHE_DIR , { recursive : true } ) ;
16+ return true ;
17+ } catch ( err ) {
18+ console . warn ( "Failed to create image cache directory:" , err ) ;
19+ return false ;
20+ }
21+ }
22+
23+ function getCacheKey ( url : string ) : string {
24+ return crypto . createHash ( "md5" ) . update ( url ) . digest ( "hex" ) ;
25+ }
26+
27+ async function getCachedImage ( url : string ) : Promise < Uint8Array | null > {
28+ const cacheKey = getCacheKey ( url ) ;
29+ const cachePath = path . join ( CACHE_DIR , cacheKey ) ;
30+
31+ try {
32+ const stats = await fs . stat ( cachePath ) ;
33+ const age = Date . now ( ) - stats . mtimeMs ;
34+
35+ if ( age > CACHE_TTL ) {
36+ return null ;
37+ }
38+
39+ const data = await fs . readFile ( cachePath ) ;
40+ return new Uint8Array ( data ) ;
41+ } catch ( err ) {
42+ return null ;
43+ }
44+ }
45+
46+ async function cacheImage ( url : string , data : Uint8Array ) : Promise < void > {
47+ if ( ! ( await ensureCacheDir ( ) ) ) {
48+ return ;
49+ }
50+
51+ const cacheKey = getCacheKey ( url ) ;
52+ const cachePath = path . join ( CACHE_DIR , cacheKey ) ;
53+
54+ try {
55+ await fs . writeFile ( cachePath , data ) ;
56+ } catch ( err ) {
57+ console . warn ( `Failed to cache image from ${ url } :` , err ) ;
58+ }
59+ }
60+
61+ async function cleanupCache ( ) : Promise < void > {
62+ try {
63+ const dirExists = await fs
64+ . access ( CACHE_DIR )
65+ . then ( ( ) => true )
66+ . catch ( ( ) => false ) ;
67+ if ( ! dirExists ) {
68+ await ensureCacheDir ( ) ;
69+ return ;
70+ }
71+
72+ const files = await fs . readdir ( CACHE_DIR ) ;
73+ const now = Date . now ( ) ;
74+
75+ for ( const file of files ) {
76+ const filePath = path . join ( CACHE_DIR , file ) ;
77+ try {
78+ const stats = await fs . stat ( filePath ) ;
79+
80+ if ( now - stats . mtimeMs > CACHE_TTL ) {
81+ await fs . unlink ( filePath ) ;
82+ }
83+ } catch ( err ) {
84+ continue ;
85+ }
86+ }
87+ } catch ( err ) {
88+ if ( err instanceof Error && "code" in err && err . code === "ENOENT" ) {
89+ await ensureCacheDir ( ) ;
90+ } else {
91+ console . warn ( "Error during cache cleanup:" , err ) ;
92+ }
93+ }
94+ }
95+
96+ async function fetchWithTimeout (
97+ url : string ,
98+ timeout : number ,
99+ retries = 0
100+ ) : Promise < Response > {
101+ const controller = new AbortController ( ) ;
102+ const id = setTimeout ( ( ) => controller . abort ( ) , timeout ) ;
103+
104+ try {
105+ const response = await fetch ( url , {
106+ signal : controller . signal ,
107+ headers : {
108+ "User-Agent" : "Mozilla/5.0 (compatible; AstroImageFetcher/1.0)" ,
109+ Accept : "image/*" ,
110+ } ,
111+ } ) ;
112+ clearTimeout ( id ) ;
113+ return response ;
114+ } catch ( error ) {
115+ clearTimeout ( id ) ;
116+
117+ if ( retries < MAX_RETRIES ) {
118+ console . log (
119+ `Retrying fetch for ${ url } (attempt ${ retries + 1 } /${ MAX_RETRIES } )...`
120+ ) ;
121+ const backoff = Math . pow ( 2 , retries ) * 1000 ;
122+ await new Promise ( ( resolve ) => setTimeout ( resolve , backoff ) ) ;
123+ return fetchWithTimeout ( url , timeout , retries + 1 ) ;
124+ }
125+
126+ throw error ;
127+ }
128+ }
129+
130+ async function createPlaceholderImage (
131+ width : number = 400 ,
132+ height : number = 300
133+ ) : Promise < Uint8Array > {
134+ try {
135+ const sharp = ( await import ( "sharp" ) ) . default ;
136+ const placeholderBuffer = await sharp ( {
137+ create : {
138+ width : width || 400 ,
139+ height : height || 300 ,
140+ channels : 3 ,
141+ background : { r : 230 , g : 230 , b : 230 } ,
142+ } ,
143+ } ) . toBuffer ( ) ;
144+
145+ return new Uint8Array ( placeholderBuffer ) ;
146+ } catch ( err ) {
147+ return new Uint8Array ( [
148+ 137 , 80 , 78 , 71 , 13 , 10 , 26 , 10 , 0 , 0 , 0 , 13 , 73 , 72 , 68 , 82 , 0 , 0 , 0 , 1 ,
149+ 0 , 0 , 0 , 1 , 8 , 6 , 0 , 0 , 0 , 31 , 21 , 196 , 137 , 0 , 0 , 0 , 10 , 73 , 68 , 65 , 84 ,
150+ 120 , 156 , 99 , 0 , 0 , 0 , 2 , 0 , 1 , 226 , 33 , 188 , 51 , 0 , 0 , 0 , 0 , 73 , 69 , 78 ,
151+ 68 , 174 , 66 , 96 , 130 ,
152+ ] ) ;
153+ }
154+ }
155+
156+ async function preloadRemoteImage ( url : string ) : Promise < Uint8Array > {
157+ try {
158+ const cachedImage = await getCachedImage ( url ) ;
159+ if ( cachedImage ) {
160+ return cachedImage ;
161+ }
162+
163+ const response = await fetchWithTimeout ( url , FETCH_TIMEOUT ) ;
164+ if ( ! response . ok ) {
165+ throw new Error ( `Failed response: ${ response . status } ` ) ;
166+ }
167+
168+ const buffer = new Uint8Array ( await response . arrayBuffer ( ) ) ;
169+ await cacheImage ( url , buffer ) ;
170+ return buffer ;
171+ } catch ( err ) {
172+ console . warn ( `Failed to preload image ${ url } :` , err ) ;
173+ return createPlaceholderImage ( ) ;
174+ }
175+ }
176+
5177const service : LocalImageService = {
6178 getURL ( options : ImageTransform ) {
7179 const searchParams = new URLSearchParams ( ) ;
8- searchParams . append (
9- "href" ,
10- typeof options . src === "string" ? options . src : options . src . src
11- ) ;
180+ const srcValue =
181+ typeof options . src === "string" ? options . src : options . src . src ;
182+
183+ if ( typeof srcValue === "string" && / ^ h t t p s ? : \/ \/ / . test ( srcValue ) ) {
184+ preloadRemoteImage ( srcValue ) . catch ( ( ) => {
185+ // Silent catch
186+ } ) ;
187+ }
188+
189+ searchParams . append ( "href" , srcValue ) ;
12190 if ( options . width ) searchParams . append ( "w" , options . width . toString ( ) ) ;
13191 if ( options . height ) searchParams . append ( "h" , options . height . toString ( ) ) ;
14192 if ( options . quality ) searchParams . append ( "q" , options . quality . toString ( ) ) ;
@@ -19,10 +197,10 @@ const service: LocalImageService = {
19197 parseURL ( url : URL ) {
20198 const params = url . searchParams ;
21199 return {
22- src : params . get ( "href" ) ! ,
23- width : params . has ( "w" ) ? parseInt ( params . get ( "w" ) ! ) : undefined ,
24- height : params . has ( "h" ) ? parseInt ( params . get ( "h" ) ! ) : undefined ,
25- quality : params . has ( "q" ) ? parseInt ( params . get ( "q" ) ! ) : undefined ,
200+ src : params . get ( "href" ) ?? "" ,
201+ width : params . has ( "w" ) ? parseInt ( params . get ( "w" ) ?? "0" ) : undefined ,
202+ height : params . has ( "h" ) ? parseInt ( params . get ( "h" ) ?? "0" ) : undefined ,
203+ quality : params . has ( "q" ) ? parseInt ( params . get ( "q" ) ?? "0" ) : undefined ,
26204 format : params . get ( "f" ) as OutputFormat | undefined ,
27205 } ;
28206 } ,
@@ -37,49 +215,65 @@ const service: LocalImageService = {
37215 format ?: OutputFormat ;
38216 }
39217 ) {
218+ const MAX_WIDTH = 1280 ;
219+ const MAX_HEIGHT = 720 ;
40220 let buffer = inputBuffer ;
41221
42222 if ( / ^ h t t p s ? : \/ \/ / . test ( transform . src ) ) {
43223 try {
44- const response = await fetch ( transform . src ) ;
45- if ( ! response . ok ) {
46- console . warn (
47- `⚠️ Failed to fetch image: ${ transform . src } (status ${ response . status } )`
48- ) ;
49- return {
50- data : buffer , // fallback to original input
51- format : transform . format ?? ( "webp" as OutputFormat ) ,
52- } ;
53- }
54- buffer = new Uint8Array ( await response . arrayBuffer ( ) ) ;
224+ const remoteImage = await preloadRemoteImage ( transform . src ) ;
225+ buffer = remoteImage ;
55226 } catch ( err ) {
56- console . warn ( `⚠️ Error fetching image from ${ transform . src } :` , err ) ;
57- return {
58- data : buffer , // fallback to original input
59- format : transform . format ?? ( "webp" as OutputFormat ) ,
60- } ;
227+ buffer = await createPlaceholderImage (
228+ transform . width ,
229+ transform . height
230+ ) ;
61231 }
62232 }
63233
64- const sharp = ( await import ( "sharp" ) ) . default ;
65- let image = sharp ( buffer ) ;
234+ const width = transform . width
235+ ? Math . min ( transform . width , MAX_WIDTH )
236+ : undefined ;
237+ const height = transform . height
238+ ? Math . min ( transform . height , MAX_HEIGHT )
239+ : undefined ;
66240
67- if ( transform . width || transform . height ) {
68- image = image . resize ( transform . width , transform . height ) ;
69- }
241+ try {
242+ const sharp = ( await import ( "sharp" ) ) . default ;
243+ let image = sharp ( buffer , { failOn : "none" } ) ;
244+ image = image . resize ( width , height ) ;
70245
71- if ( transform . format ) {
72- image = image . toFormat ( transform . format , {
73- quality : transform . quality ,
74- } ) ;
75- }
246+ if ( transform . format ) {
247+ image = image . toFormat ( transform . format , {
248+ quality : transform . quality ,
249+ } ) ;
250+ }
76251
77- const outputBuffer = await image . toBuffer ( ) ;
252+ const outputBuffer = await image . toBuffer ( ) ;
253+ return {
254+ data : outputBuffer ,
255+ format : transform . format ?? ( "webp" as OutputFormat ) ,
256+ } ;
257+ } catch ( err ) {
258+ console . warn ( `⚠️ Error processing image: ${ transform . src } ` , err ) ;
78259
79- return {
80- data : Uint8Array . from ( outputBuffer ) ,
81- format : transform . format ?? ( "webp" as OutputFormat ) ,
82- } ;
260+ try {
261+ const placeholderBuffer = await createPlaceholderImage (
262+ transform . width || 400 ,
263+ transform . height || 300
264+ ) ;
265+
266+ return {
267+ data : placeholderBuffer ,
268+ format : transform . format ?? ( "webp" as OutputFormat ) ,
269+ } ;
270+ } catch ( placeholderErr ) {
271+ return {
272+ data : buffer ,
273+ format : transform . format ?? ( "webp" as OutputFormat ) ,
274+ } ;
275+ }
276+ }
83277 } ,
84278
85279 getHTMLAttributes ( options ) {
@@ -96,7 +290,6 @@ const service: LocalImageService = {
96290 }
97291
98292 const { src, width, height, format, quality, ...attributes } = options ;
99-
100293 return {
101294 ...attributes ,
102295 width : targetWidth ,
@@ -109,4 +302,16 @@ const service: LocalImageService = {
109302 propertiesToHash : [ "src" , "width" , "height" , "format" , "quality" ] ,
110303} ;
111304
305+ ensureCacheDir ( )
306+ . then ( ( success ) => {
307+ if ( success ) {
308+ return cleanupCache ( ) . catch ( ( ) => {
309+ /* Ignore errors */
310+ } ) ;
311+ }
312+ } )
313+ . catch ( ( ) => {
314+ /* Ignore errors */
315+ } ) ;
316+
112317export default service ;
0 commit comments