Files
windwiderstand/src/lib/transform-cache.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

126 lines
3.7 KiB
TypeScript

/**
* Server-seitiger Cache für /api/transform (Bilder vom CMS).
* Reduziert Aufrufe zum CMS; gleiche Parameter = gleiche Antwort (immutable).
*
* - In-Memory: LRU-artig, begrenzt auf TRANSFORM_CACHE_MAX_ENTRIES (Default 100).
* - Optional Datei-Cache: TRANSFORM_CACHE_DIR setzen (z. B. .cache/transform).
*/
import { env } from '$env/dynamic/private';
const MAX_ENTRIES = Number(env.TRANSFORM_CACHE_MAX_ENTRIES) || 100;
const CACHE_DIR = env.TRANSFORM_CACHE_DIR?.trim() || '';
interface CachedImage {
buffer: ArrayBuffer;
contentType: string;
}
const memoryCache = new Map<string, CachedImage>();
/** Cache-Key aus Query-String (eindeutig pro URL + Parameter). */
function cacheKey(params: string): string {
return params;
}
/** In-Memory: ältesten Eintrag entfernen (Map behält Einfüge-Reihenfolge). */
function evictOldest(): void {
const first = memoryCache.keys().next();
if (first.value !== undefined) memoryCache.delete(first.value);
}
/** Prüft, ob Datei-Cache verfügbar ist (nur Server mit fs). */
async function getCacheDir(): Promise<string | null> {
if (!CACHE_DIR) return null;
try {
const { existsSync, mkdirSync } = await import('node:fs');
const { join } = await import('node:path');
const dir = join(process.cwd(), CACHE_DIR);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
} catch {
return null;
}
}
/** Sicheren Dateinamen aus Key erzeugen (Hash). */
function fileKey(key: string): string {
let h = 0;
for (let i = 0; i < key.length; i++)
h = (Math.imul(31, h) + key.charCodeAt(i)) | 0;
return Math.abs(h).toString(36) + key.length.toString(36);
}
/** Aus Datei-Cache lesen. */
async function getFromFile(key: string): Promise<CachedImage | null> {
const dir = await getCacheDir();
if (!dir) return null;
try {
const { readFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const base = fileKey(key);
const metaPath = join(dir, `${base}.meta`);
const dataPath = join(dir, `${base}.bin`);
const [ct, buf] = await Promise.all([
readFile(metaPath, 'utf8').catch(() => null),
readFile(dataPath).catch(() => null),
]);
if (ct && buf)
return {
buffer: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
contentType: ct,
};
} catch {
// ignore
}
return null;
}
/** In Datei-Cache schreiben. */
async function setInFile(key: string, value: CachedImage): Promise<void> {
const dir = await getCacheDir();
if (!dir) return;
try {
const { writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const base = fileKey(key);
await Promise.all([
writeFile(join(dir, `${base}.meta`), value.contentType),
writeFile(join(dir, `${base}.bin`), Buffer.from(value.buffer)),
]);
} catch {
// ignore
}
}
/**
* Transform-Ergebnis aus Cache holen (Memory zuerst, dann Datei).
*/
export async function getCachedTransform(params: string): Promise<CachedImage | null> {
const key = cacheKey(params);
const mem = memoryCache.get(key);
if (mem) return mem;
const file = await getFromFile(key);
if (file) {
if (memoryCache.size >= MAX_ENTRIES) evictOldest();
memoryCache.set(key, file);
return file;
}
return null;
}
/**
* Transform-Ergebnis cachen (Memory + optional Datei).
*/
export async function setCachedTransform(
params: string,
buffer: ArrayBuffer,
contentType: string,
): Promise<void> {
const key = cacheKey(params);
const value: CachedImage = { buffer, contentType };
if (memoryCache.size >= MAX_ENTRIES) evictOldest();
memoryCache.set(key, value);
await setInFile(key, value);
}