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>
130 lines
3.8 KiB
TypeScript
130 lines
3.8 KiB
TypeScript
/**
|
|
* Persistenter Disk-Cache für CMS-Dateien (PDF, DOCX, ...).
|
|
* Analog zu image-cache.server.ts: Datei einmal vom CMS holen, auf Disk
|
|
* speichern, danach direkt von dort ausliefern.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
import { extname, join } from 'node:path';
|
|
import { env } from '$env/dynamic/private';
|
|
|
|
const CACHE_DIR = env.FILE_CACHE_DIR?.trim() || '.cache/files';
|
|
const MAX_MEMORY_ENTRIES = Number(env.FILE_CACHE_MAX_MEMORY) || 50;
|
|
|
|
export interface CachedFile {
|
|
buffer: Buffer;
|
|
contentType: string;
|
|
ext: string;
|
|
}
|
|
|
|
const memoryCache = new Map<string, CachedFile>();
|
|
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;
|
|
}
|
|
|
|
export function buildFileCacheKey(src: string): string {
|
|
return createHash('sha256').update(src).digest('hex').slice(0, 24);
|
|
}
|
|
|
|
const EXT_TO_CT: Record<string, string> = {
|
|
pdf: 'application/pdf',
|
|
doc: 'application/msword',
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
xls: 'application/vnd.ms-excel',
|
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
ppt: 'application/vnd.ms-powerpoint',
|
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
odt: 'application/vnd.oasis.opendocument.text',
|
|
ods: 'application/vnd.oasis.opendocument.spreadsheet',
|
|
zip: 'application/zip',
|
|
txt: 'text/plain; charset=utf-8',
|
|
csv: 'text/csv; charset=utf-8',
|
|
json: 'application/json',
|
|
xml: 'application/xml',
|
|
rtf: 'application/rtf',
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
png: 'image/png',
|
|
webp: 'image/webp',
|
|
gif: 'image/gif',
|
|
svg: 'image/svg+xml',
|
|
};
|
|
|
|
export function contentTypeFromExt(ext: string): string {
|
|
return EXT_TO_CT[ext.toLowerCase()] ?? 'application/octet-stream';
|
|
}
|
|
|
|
export function extFromContentType(ct: string): string | null {
|
|
const lower = ct.toLowerCase();
|
|
for (const [ext, mime] of Object.entries(EXT_TO_CT)) {
|
|
if (lower.startsWith(mime.split(';')[0])) return ext;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function extFromSrc(src: string): string | null {
|
|
try {
|
|
const path = src.split('?')[0].split('#')[0];
|
|
const e = extname(path).slice(1).toLowerCase();
|
|
return e || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rememberInMemory(key: string, cached: CachedFile): CachedFile {
|
|
if (memoryCache.size >= MAX_MEMORY_ENTRIES) {
|
|
const oldest = memoryCache.keys().next().value;
|
|
if (oldest !== undefined) memoryCache.delete(oldest);
|
|
}
|
|
memoryCache.set(key, cached);
|
|
return cached;
|
|
}
|
|
|
|
export async function getCachedFile(key: string): Promise<CachedFile | null> {
|
|
const mem = memoryCache.get(key);
|
|
if (mem) return mem;
|
|
|
|
const dir = ensureCacheDir();
|
|
try {
|
|
const files = await readdir(dir);
|
|
const prefix = `${key}.`;
|
|
const name = files.find((f) => f.startsWith(prefix));
|
|
if (!name) return null;
|
|
const buf = await readFile(join(dir, name));
|
|
const ext = name.slice(prefix.length);
|
|
return rememberInMemory(key, {
|
|
buffer: buf,
|
|
contentType: contentTypeFromExt(ext),
|
|
ext,
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function cacheFile(
|
|
key: string,
|
|
buffer: ArrayBuffer | Buffer,
|
|
contentType: string,
|
|
srcHint?: string,
|
|
): Promise<CachedFile> {
|
|
const dir = ensureCacheDir();
|
|
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
const ext =
|
|
extFromContentType(contentType) ??
|
|
(srcHint ? extFromSrc(srcHint) : null) ??
|
|
'bin';
|
|
const cached: CachedFile = { buffer: buf, contentType, ext };
|
|
rememberInMemory(key, cached);
|
|
await writeFile(join(dir, `${key}.${ext}`), buf);
|
|
return cached;
|
|
}
|