615d9cdbd1
Toolchain hygiene:
- svelte-check is now a pinned devDep (was npx-fetched per CI run)
- vitest + coverage as devDeps; vitest.config.ts is separate from
vite.config.ts so vite build does not pull vitest
- $env stub for vitest (alias resolves $env/dynamic/{public,private}
to a process.env proxy) — keeps server modules testable without
booting SvelteKit
- npm scripts: check / check:watch / typecheck / test / test:watch
- drop yarn.lock + packageManager field; CI uses npm ci, single source
of truth is package-lock.json
- CI runs generate:icons + svelte-check + tsc --noEmit before deploy
Switch from raw process.env.X to $env/dynamic/{public,private} in
file-cache, image-cache, transform-cache and rusty-image.server. The
typed imports get tree-shaken correctly per server/client and fail
loudly at build time on accidental client-side use of private vars.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
246 lines
6.8 KiB
TypeScript
246 lines
6.8 KiB
TypeScript
/**
|
||
* Persistenter Disk-Cache für CMS-Bilder.
|
||
* Bilder werden einmal vom CMS geholt, auf Disk gespeichert und danach
|
||
* direkt von dort ausgeliefert – kein erneuter CMS-Request nötig.
|
||
*
|
||
* Zwei Ebenen: In-Memory (LRU) für häufig angefragte Bilder,
|
||
* Disk für Persistenz über Neustarts hinweg.
|
||
*/
|
||
|
||
import { createHash } from 'node:crypto';
|
||
import { existsSync, mkdirSync } from 'node:fs';
|
||
import { readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||
import { join } from 'node:path';
|
||
import { env } from '$env/dynamic/private';
|
||
|
||
const CACHE_DIR = env.IMAGE_CACHE_DIR?.trim() || '.cache/images';
|
||
const MAX_MEMORY_ENTRIES = Number(env.IMAGE_CACHE_MAX_MEMORY) || 200;
|
||
|
||
export interface CachedImage {
|
||
buffer: Buffer;
|
||
contentType: string;
|
||
}
|
||
|
||
export interface ImageTransformParams {
|
||
w?: number;
|
||
h?: number;
|
||
q?: number;
|
||
format?: string;
|
||
fit?: string;
|
||
ar?: string;
|
||
fx?: number;
|
||
fy?: number;
|
||
dpr?: number;
|
||
}
|
||
|
||
const memoryCache = new Map<string, CachedImage>();
|
||
|
||
let resolvedDir: string | null = null;
|
||
|
||
function ensureCacheDir(): string {
|
||
const dir = resolvedDir ?? join(process.cwd(), CACHE_DIR);
|
||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||
resolvedDir = dir;
|
||
return dir;
|
||
}
|
||
|
||
/**
|
||
* Deterministischer Cache-Key aus src + allen Transform-Parametern.
|
||
* Gleiche Eingabe = gleicher Key, unabhängig von Reihenfolge.
|
||
*/
|
||
function roundFocal(v: number): number {
|
||
return Math.round(v * 10000) / 10000;
|
||
}
|
||
|
||
export function buildCacheKey(src: string, params: ImageTransformParams): string {
|
||
const parts = [src];
|
||
if (params.w != null) parts.push(`w=${params.w}`);
|
||
if (params.h != null) parts.push(`h=${params.h}`);
|
||
if (params.q != null) parts.push(`q=${params.q}`);
|
||
if (params.format) parts.push(`f=${params.format}`);
|
||
if (params.fit) parts.push(`fit=${params.fit}`);
|
||
if (params.ar) parts.push(`ar=${params.ar}`);
|
||
if (params.fx != null) parts.push(`fx=${roundFocal(params.fx)}`);
|
||
if (params.fy != null) parts.push(`fy=${roundFocal(params.fy)}`);
|
||
if (params.dpr != null) parts.push(`dpr=${roundFocal(params.dpr)}`);
|
||
return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
|
||
}
|
||
|
||
function extForContentType(ct: string): string {
|
||
if (ct.includes('webp')) return 'webp';
|
||
if (ct.includes('avif')) return 'avif';
|
||
if (ct.includes('png')) return 'png';
|
||
if (ct.includes('svg')) return 'svg';
|
||
if (ct.includes('gif')) return 'gif';
|
||
return 'jpg';
|
||
}
|
||
|
||
/** Content-Type für HTTP-Header aus Dateiendung (eine Datei pro Cache-Eintrag). */
|
||
export function contentTypeFromExtension(ext: string): string {
|
||
switch (ext.toLowerCase()) {
|
||
case 'webp':
|
||
return 'image/webp';
|
||
case 'jpg':
|
||
case 'jpeg':
|
||
return 'image/jpeg';
|
||
case 'png':
|
||
return 'image/png';
|
||
case 'avif':
|
||
return 'image/avif';
|
||
case 'gif':
|
||
return 'image/gif';
|
||
case 'svg':
|
||
return 'image/svg+xml';
|
||
default:
|
||
return 'image/jpeg';
|
||
}
|
||
}
|
||
|
||
const IMAGE_FILE_EXT = new Set(['webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'svg']);
|
||
|
||
function rememberInMemory(key: string, cached: CachedImage): CachedImage {
|
||
if (memoryCache.size >= MAX_MEMORY_ENTRIES) {
|
||
const oldest = memoryCache.keys().next().value;
|
||
if (oldest !== undefined) memoryCache.delete(oldest);
|
||
}
|
||
memoryCache.set(key, cached);
|
||
return cached;
|
||
}
|
||
|
||
/**
|
||
* Liest einen Cache-Eintrag: eine Datei `{key}.{webp|jpg|…}` (ein I/O).
|
||
* Fallback: altes Paar `.meta` + `.bin`. Optional `params.format`, um die Endung vorab zu probieren.
|
||
*/
|
||
export async function getCachedImage(
|
||
key: string,
|
||
params?: ImageTransformParams,
|
||
): Promise<CachedImage | null> {
|
||
const mem = memoryCache.get(key);
|
||
if (mem) return mem;
|
||
|
||
const dir = ensureCacheDir();
|
||
|
||
const tryFile = async (ext: string): Promise<CachedImage | null> => {
|
||
try {
|
||
const buf = await readFile(join(dir, `${key}.${ext}`));
|
||
const cached: CachedImage = {
|
||
buffer: buf,
|
||
contentType: contentTypeFromExtension(ext),
|
||
};
|
||
return rememberInMemory(key, cached);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
if (params?.format) {
|
||
const ext = resolveExtension(params);
|
||
const hit = await tryFile(ext);
|
||
if (hit) return hit;
|
||
if (ext === 'jpg') {
|
||
const hitJpeg = await tryFile('jpeg');
|
||
if (hitJpeg) return hitJpeg;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const files = await readdir(dir);
|
||
const prefix = `${key}.`;
|
||
const imageName = files.find((f) => {
|
||
if (!f.startsWith(prefix)) return false;
|
||
const rest = f.slice(prefix.length).toLowerCase();
|
||
if (rest === 'meta' || rest === 'bin') return false;
|
||
return IMAGE_FILE_EXT.has(rest);
|
||
});
|
||
if (imageName) {
|
||
try {
|
||
const buf = await readFile(join(dir, imageName));
|
||
const ext = imageName.slice(prefix.length);
|
||
return rememberInMemory(key, {
|
||
buffer: buf,
|
||
contentType: contentTypeFromExtension(ext),
|
||
});
|
||
} catch {
|
||
// weiter zu Legacy
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
const metaPath = join(dir, `${key}.meta`);
|
||
const dataPath = join(dir, `${key}.bin`);
|
||
try {
|
||
const [contentType, buf] = await Promise.all([
|
||
readFile(metaPath, 'utf8'),
|
||
readFile(dataPath),
|
||
]);
|
||
return rememberInMemory(key, { buffer: buf, contentType });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function cacheImage(
|
||
key: string,
|
||
buffer: ArrayBuffer | Buffer,
|
||
contentType: string,
|
||
): Promise<CachedImage> {
|
||
const dir = ensureCacheDir();
|
||
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||
const ext = extForContentType(contentType);
|
||
const cached: CachedImage = { buffer: buf, contentType };
|
||
|
||
rememberInMemory(key, cached);
|
||
|
||
const base = join(dir, key);
|
||
try {
|
||
const files = await readdir(dir);
|
||
const prefix = `${key}.`;
|
||
for (const f of files) {
|
||
if (!f.startsWith(prefix)) continue;
|
||
await unlink(join(dir, f)).catch(() => {});
|
||
}
|
||
} catch {
|
||
await unlink(`${base}.meta`).catch(() => {});
|
||
await unlink(`${base}.bin`).catch(() => {});
|
||
}
|
||
|
||
await writeFile(join(dir, `${key}.${ext}`), buf);
|
||
|
||
return cached;
|
||
}
|
||
|
||
/**
|
||
* Dateiendung aus den Transform-Parametern oder Content-Type ableiten.
|
||
*/
|
||
export function resolveExtension(params: ImageTransformParams, contentType?: string): string {
|
||
if (params.format) return params.format === 'jpeg' ? 'jpg' : params.format;
|
||
if (contentType) return extForContentType(contentType);
|
||
return 'jpg';
|
||
}
|
||
|
||
/**
|
||
* Wipe in-memory + on-disk image cache. Returns counts cleared.
|
||
*/
|
||
export async function clearImageCache(): Promise<{ memory: number; disk: number }> {
|
||
const memory = memoryCache.size;
|
||
memoryCache.clear();
|
||
let disk = 0;
|
||
try {
|
||
const dir = ensureCacheDir();
|
||
const files = await readdir(dir);
|
||
for (const f of files) {
|
||
try {
|
||
await unlink(join(dir, f));
|
||
disk++;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return { memory, disk };
|
||
}
|