From ac8f1004dcef888fc9a00af20ca441f55c7fd3a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:04:22 +0000 Subject: [PATCH 1/3] Initial plan From 82301a9d2790f9e8814395c2b73ea10014748a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:10:06 +0000 Subject: [PATCH 2/3] Improve image loading with streaming, caching, and lazy loading Co-authored-by: patrick11514 <56652391+patrick11514@users.noreply.github.com> --- src/components/utility/Image.svelte | 12 ++-- src/routes/image/[name]/+server.ts | 107 ++++++++++++++++++++-------- 2 files changed, 84 insertions(+), 35 deletions(-) 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..c99569c 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,49 @@ 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) + }); + } + + // 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); }); - return new Response(content); + return new Response(webStream, { + headers: getCacheHeaders(fileExtension, fileInfo.size, etag) + }); }) satisfies RequestHandler; From 7070e5a9080a4d1aadf99d9342597dace92bfda9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:12:11 +0000 Subject: [PATCH 3/3] Add error handling for background cache operation Co-authored-by: patrick11514 <56652391+patrick11514@users.noreply.github.com> --- src/routes/image/[name]/+server.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/routes/image/[name]/+server.ts b/src/routes/image/[name]/+server.ts index c99569c..0406e16 100644 --- a/src/routes/image/[name]/+server.ts +++ b/src/routes/image/[name]/+server.ts @@ -219,9 +219,13 @@ export const GET = (async ({ params, url, request }) => { const webStream = nodeStreamToWebStream(fileStream); // Read and cache the file in the background for future requests - fs.readFile(filePath).then((buffer) => { - memoryCache.set(filePath, buffer); - }); + 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)