Skip to content

Commit 4c97d0d

Browse files
committed
Update image service.
1 parent 5fa5e37 commit 4c97d0d

File tree

2 files changed

+246
-41
lines changed

2 files changed

+246
-41
lines changed

src/image.service.ts

Lines changed: 245 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,192 @@
11
import 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

36
type 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+
5177
const 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" && /^https?:\/\//.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 (/^https?:\/\//.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+
112317
export default service;

src/pages/speaker/[slug].astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ let avatar: any;
2222
2323
if (entry.data.avatar){
2424
try {
25-
avatar = getImage({ src: entry.data.avatar , width:600, height:400, alt: 'User avatar' });
25+
avatar = getImage({ src: entry.data.avatar , alt: 'User avatar' });
2626
} catch (e) {
2727
//TODO: improve placeholders and offline
2828
//avatar = await getImage({ src: 'https://placehold.co/600x400?text=x', width: '600', height:'400', alt: 'Default avatar' });

0 commit comments

Comments
 (0)