diff --git a/src/components/utility/Image.svelte b/src/components/utility/Image.svelte index 1d39f3f..a5ac9bc 100644 --- a/src/components/utility/Image.svelte +++ b/src/components/utility/Image.svelte @@ -8,12 +8,12 @@ - - - - - + + + + + - + diff --git a/src/routes/image/[name]/+server.ts b/src/routes/image/[name]/+server.ts index 6224930..0406e16 100644 --- a/src/routes/image/[name]/+server.ts +++ b/src/routes/image/[name]/+server.ts @@ -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'; @@ -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; @@ -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 { + 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'); } @@ -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; @@ -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); @@ -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 @@ -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;