Files
windwiderstand/src/lib/image-cache.server.ts
T
Peter Meier 615d9cdbd1 chore(tooling): add vitest + svelte-check, drop yarn.lock, CI gates, typed env imports
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>
2026-05-04 16:09:02 +02:00

246 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}