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)