Files
windwiderstand/src/lib/rusty-image.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

287 lines
8.0 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.
/**
* Server-only Bild-Helfer: Asset-Meta vom CMS, Blurhash-LQIP, Responsive-Srcset.
* Nur in `+page.server.ts` / `+layout.server.ts` importieren — nutzt sharp + blurhash.
*/
import { env } from '$env/dynamic/public';
import {
getCmsImageUrl,
type RustyImageTransformParams,
type CmsImageField,
} from './rusty-image';
function getCmsBaseUrl(): string {
return (env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, '');
}
export interface AssetMeta {
width: number;
height: number;
hasAlpha: boolean;
mimeType: string;
blurhash?: string;
}
const metaCache = new Map<string, Promise<AssetMeta | null>>();
function toMetaEndpoint(url: string): string | null {
try {
const u = new URL(url);
const base = getCmsBaseUrl();
const baseHost = new URL(base).host;
if (u.host !== baseHost && !/^127\.0\.0\.1|localhost/.test(u.host)) {
return null;
}
u.searchParams.set('meta', 'true');
return `${base}${u.pathname}${u.search}`;
} catch {
return null;
}
}
/**
* Holt AssetInfo vom CMS: `/api/assets/<path>?meta=true`.
* Cached per URL für die Prozess-Lebensdauer.
*/
export async function fetchAssetMeta(url: string): Promise<AssetMeta | null> {
const endpoint = toMetaEndpoint(url);
if (!endpoint) return null;
const cached = metaCache.get(endpoint);
if (cached) return cached;
const p = (async () => {
try {
const res = await fetch(endpoint);
if (!res.ok) return null;
const json = (await res.json()) as {
width?: number;
height?: number;
has_alpha?: boolean;
mime_type?: string;
blurhash?: string;
};
if (
typeof json?.width !== 'number' ||
typeof json?.height !== 'number' ||
json.width <= 0 ||
json.height <= 0
) {
return null;
}
return {
width: json.width,
height: json.height,
hasAlpha: Boolean(json.has_alpha),
mimeType: json.mime_type ?? 'image/jpeg',
...(typeof json.blurhash === 'string' && json.blurhash.trim()
? { blurhash: json.blurhash }
: {}),
} satisfies AssetMeta;
} catch {
return null;
}
})();
metaCache.set(endpoint, p);
return p;
}
const blurhashCache = new Map<string, Promise<string | null>>();
/**
* Dekodiert Blurhash → 32×32 RGBA → JPEG Q40 → base64 data-URL.
* Liefert kompakten LQIP (~300500 B) für CSS-`background-image`.
*/
export async function blurhashToDataUrl(
hash: string,
width = 32,
height = 32,
): Promise<string | null> {
const key = `${hash}|${width}x${height}`;
const cached = blurhashCache.get(key);
if (cached) return cached;
const p = (async () => {
try {
const [{ decode }, sharpMod] = await Promise.all([
import('blurhash'),
import('sharp'),
]);
const pixels = decode(hash, width, height);
const sharp = sharpMod.default;
const jpeg = await sharp(Buffer.from(pixels), {
raw: { width, height, channels: 4 },
})
.jpeg({ quality: 40 })
.toBuffer();
return `data:image/jpeg;base64,${jpeg.toString('base64')}`;
} catch {
return null;
}
})();
blurhashCache.set(key, p);
return p;
}
export interface ResponsiveImage {
sources: { type: string; srcset: string }[];
fallback: string;
width: number;
height: number;
widths: number[];
lqip?: string;
}
export interface ResponsiveImageOptions {
widths?: number[];
ar?: string;
fit?: RustyImageTransformParams['fit'];
formats?: Array<'avif' | 'webp' | 'jpeg'>;
quality?: number;
focal?: CmsImageField['focal'];
lqip?: boolean;
}
const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['webp', 'jpeg'];
const MIME_FOR_FORMAT: Record<'avif' | 'webp' | 'jpeg', string> = {
avif: 'image/avif',
webp: 'image/webp',
jpeg: 'image/jpeg',
};
function parseAr(ar: string | undefined): number | null {
if (!ar) return null;
const m = ar.match(/^(\d+(?:\.\d+)?)\s*[/:x]\s*(\d+(?:\.\d+)?)$/);
if (!m) return null;
const w = Number(m[1]);
const h = Number(m[2]);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
return w / h;
}
/**
* Baut `<picture>`-Daten (AVIF/WebP/JPEG srcset + Fallback + LQIP).
* Breiten werden gegen die echte Bildbreite aus `fetchAssetMeta` geclampt (kein Upscaling).
* Alle URLs zeigen auf `/cms-images?…` (erst bei Client-Request wird gerendert & gecached).
*/
export async function buildResponsiveImage(
url: string,
opts: ResponsiveImageOptions = {},
): Promise<ResponsiveImage | null> {
const meta = await fetchAssetMeta(url);
const widthsRaw = (opts.widths ?? DEFAULT_WIDTHS).filter((w) => Number.isFinite(w) && w > 0);
if (widthsRaw.length === 0) return null;
const maxW = meta?.width ?? Math.max(...widthsRaw);
const widths = [...new Set(widthsRaw.map((w) => Math.min(w, maxW)).filter((w) => w > 0))].sort(
(a, b) => a - b,
);
if (widths.length === 0) return null;
const ar =
parseAr(opts.ar) ??
(meta?.width && meta?.height ? meta.width / meta.height : null);
const formats = opts.formats ?? DEFAULT_FORMATS;
const fit = opts.fit ?? 'cover';
const quality = opts.quality ?? 78;
const baseParams: RustyImageTransformParams = {
fit,
quality,
...(opts.ar ? { ar: opts.ar } : {}),
...(opts.focal
? { focalX: opts.focal.x, focalY: opts.focal.y }
: {}),
};
const sources = formats.map((fmt) => {
const srcset = widths
.map((w) => {
const u = getCmsImageUrl(url, { ...baseParams, width: w, format: fmt });
return `${u} ${w}w`;
})
.join(', ');
return { type: MIME_FOR_FORMAT[fmt], srcset };
});
const fallbackWidth = widths[widths.length - 1] ?? maxW;
const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp';
const fallback = getCmsImageUrl(url, {
...baseParams,
width: fallbackWidth,
format: fallbackFormat,
});
const width = fallbackWidth;
const height = ar ? Math.round(width / ar) : meta?.height ?? Math.round(width * 0.5625);
const lqip =
opts.lqip !== false && meta?.blurhash
? ((await blurhashToDataUrl(meta.blurhash)) ?? undefined)
: undefined;
return {
sources,
fallback,
width,
height,
widths,
...(lqip ? { lqip } : {}),
};
}
/**
* Fire-and-forget Cache-Warmup: alle srcset-Varianten + Fallback parallel anrequesten.
* Nutzt SvelteKit's `event.fetch` für Self-Calls (keine echten HTTP-Roundtrips in Prod-SSR).
* Blockiert den Response NICHT — läuft im Hintergrund weiter.
*/
export function warmResponsiveImage(
responsive: ResponsiveImage | null,
fetchFn: typeof fetch,
): void {
if (!responsive) return;
const urls = new Set<string>();
urls.add(responsive.fallback);
for (const s of responsive.sources) {
for (const entry of s.srcset.split(',')) {
const url = entry.trim().split(/\s+/)[0];
if (url) urls.add(url);
}
}
for (const u of urls) {
void fetchFn(u).catch(() => {});
}
}
/**
* Fire-and-forget Warmup für PostCard-Bilder (Grid: 400px base, 2x density, webp+jpeg).
* Spiegelt die URLs, die <RustyImage width={400} aspect="3/2"/> zur Laufzeit anfragt.
*/
export function warmPostCardImages(
posts: Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
fetchFn: typeof fetch,
limit = 6,
): void {
const widths = [400, 800];
const formats: Array<'webp' | 'jpeg'> = ['webp', 'jpeg'];
for (const post of posts.slice(0, limit)) {
const raw = post._rawImageUrl;
if (!raw) continue;
const focal = post._imageFocal ?? undefined;
for (const w of widths) {
for (const format of formats) {
const url = getCmsImageUrl(raw, {
width: w,
height: Math.round((w / 3) * 2),
fit: 'cover',
format,
quality: 75,
ar: '3/2',
...(focal ? { focalX: focal.x, focalY: focal.y } : {}),
});
void fetchFn(url).catch(() => {});
}
}
}
}