Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/components/utility/Image.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
</script>

<picture>
<!--mobile!-->
<source srcset="{src}&scale=50" type="image/{format}" sizes="(max-width: 640px)" />
<!--tablet!-->
<source srcset="{src}&scale=75" type="image/{format}" sizes="(max-width: 1024px)" />
<!--desktop!-->
<!--mobile - screens up to 640px!-->
<source srcset="{src}&scale=50" type="image/{format}" media="(max-width: 640px)" />
<!--tablet - screens up to 1024px!-->
<source srcset="{src}&scale=75" type="image/{format}" media="(max-width: 1024px)" />
<!--desktop - larger screens!-->
<source srcset={src} type="image/{format}" />
<!--fallback!-->
<img class={cls} {alt} {src} loading="lazy" decoding="async" />
<img class={cls} {alt} {src} loading="lazy" decoding="async" fetchpriority="low" />
</picture>
113 changes: 83 additions & 30 deletions src/routes/image/[name]/+server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { error, type RequestHandler } from '@sveltejs/kit';
import Path from 'node:path';
import fs from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { FILE_FOLDER } from '$env/static/private';
import sharp from 'sharp';
import { isDirectory, isFile } from '$/lib/server/functions';
Expand All @@ -9,6 +11,9 @@ import { extensions, type ImageExtension } from '$/types/types';
const CACHE_FOLDER = '.cache';
const DEFAULT_IMAGE_QUALITY = 75;

// Long cache duration - 1 year in seconds
const CACHE_MAX_AGE = 31536000;

type CacheEntry = {
buffer: Buffer;
timestamp: number;
Expand Down Expand Up @@ -52,7 +57,42 @@ class MemoryCache {

const memoryCache = new MemoryCache();

export const GET = (async ({ params, setHeaders, url }) => {
/**
* Build common cache headers for image responses
*/
function getCacheHeaders(fileExtension: string, contentLength: number, etag: string) {
return {
'Content-Type': `image/${fileExtension}`,
'Content-Length': contentLength.toString(),
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}, immutable`,
ETag: etag,
Vary: 'Accept'
};
}

/**
* Convert Node.js readable stream to web ReadableStream
*/
function nodeStreamToWebStream(nodeStream: Readable): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk));
});
nodeStream.on('end', () => {
controller.close();
});
nodeStream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
nodeStream.destroy();
}
});
}

export const GET = (async ({ params, url, request }) => {
if (!params.name) {
error(400, 'Name is required');
}
Expand Down Expand Up @@ -84,7 +124,7 @@ export const GET = (async ({ params, setHeaders, url }) => {
if (searchParams.has('format')) {
const format = searchParams.get('format')!;
if (!extensions.includes(format as ImageExtension)) {
throw error(400, 'Bad request');
error(400, 'Bad request');
}

fileExtension = format;
Expand All @@ -96,19 +136,17 @@ export const GET = (async ({ params, setHeaders, url }) => {
try {
const downScale = parseInt(downscale);
if (downScale > 100 || downScale < 0) {
throw error(400, 'Bad request');
error(400, 'Bad request');
}

modified = true;
scale = downScale;
//eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
throw error(400, 'Bad request');
error(400, 'Bad request');
}
}

let content: Buffer;

if (modified) {
if (!(await isDirectory(CACHE_FOLDER))) {
await fs.mkdir(CACHE_FOLDER);
Expand All @@ -117,10 +155,10 @@ export const GET = (async ({ params, setHeaders, url }) => {
const cacheModifiedName = `${Path.basename(params.name)}.scale-${scale}.${fileExtension}`;
const cachePath = Path.join(CACHE_FOLDER, cacheModifiedName);

// Check if we need to generate the cached version
if (!(await isFile(cachePath))) {
//here we don't want to assign the out of scope variable `content`
const content = await fs.readFile(filePath);
let image = sharp(content);
const originalContent = await fs.readFile(filePath);
let image = sharp(originalContent);

const imageOptions: sharp.JpegOptions & sharp.PngOptions & sharp.WebpOptions & sharp.TiffOptions = {
quality: DEFAULT_IMAGE_QUALITY
Expand All @@ -143,38 +181,53 @@ export const GET = (async ({ params, setHeaders, url }) => {
}

const meta = await image.metadata();

const newWidth = meta.width ? Math.round(meta.width * (scale / 100)) : undefined;
const newHeight = meta.height ? Math.round(meta.height * (scale / 100)) : undefined;

image = image.resize(newWidth, newHeight);

const imageBuffer = await image.toBuffer();
await fs.writeFile(cachePath, imageBuffer);
memoryCache.set(cachePath, imageBuffer);
}

if (memoryCache.get(cachePath)) {
content = memoryCache.get(cachePath)!;
} else {
content = await fs.readFile(cachePath);
memoryCache.set(cachePath, content);
}
filePath = cachePath;
} else {
if (memoryCache.get(filePath)) {
content = memoryCache.get(filePath)!;
} else {
content = await fs.readFile(filePath);
memoryCache.set(filePath, content);
}
}

// Get file info for ETag and Content-Length
const fileInfo = await fs.stat(filePath);
const etag = `"${fileInfo.mtime.getTime().toString(16)}-${fileInfo.size.toString(16)}"`;

// Check for conditional request (If-None-Match)
const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch === etag) {
return new Response(null, {
status: 304,
headers: getCacheHeaders(fileExtension, fileInfo.size, etag)
});
}

setHeaders({
'Content-Type': `image/${fileExtension}`,
'Content-Length': fileInfo.size.toString(),
'Cache-Control': 'public, max-age=31536000, immutable'
});
// Check memory cache first
const cachedContent = memoryCache.get(filePath);
if (cachedContent) {
return new Response(cachedContent, {
headers: getCacheHeaders(fileExtension, cachedContent.length, etag)
});
}

return new Response(content);
// Stream the file for non-cached content
const fileStream = createReadStream(filePath);
const webStream = nodeStreamToWebStream(fileStream);

// Read and cache the file in the background for future requests
fs.readFile(filePath)
.then((buffer) => {
memoryCache.set(filePath, buffer);
})
.catch(() => {
// Silently ignore background caching errors - the file was already streamed successfully
});

return new Response(webStream, {
headers: getCacheHeaders(fileExtension, fileInfo.size, etag)
});
}) satisfies RequestHandler;