perf: review-fixes (N+1, waterfalls, sanitize-cache, lazy strommix)
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m3s

- blog-utils.resolvePostOverviewBlocks: parallel getPostBySlug via
  Promise.all + .catch(null) instead of sequential await loop.
- blog-utils.resolveCalendarBlocks: same fix for calendar items.
- [...slug] page load: SearchableText/Calendar/DeadlineBanner now run
  parallel after tagsMap (was strict waterfall).
- sanitize-blocks: LRU cache keyed on raw html input — sanitize-html is
  ~ms/KB, the same content repeats across pages and cache hits.
- cms.getNormalizedTranslations: WeakMap-memoised the per-request
  Object.entries normalize loop on top of the existing 5-min bundle TTL.
- cms.cached: opt-in dev cache via DEV_CACHE_CMS=1 (default still off).
- rusty-image.ensureTransformedImage: was async-wrapping a sync URL
  builder; made sync, removed awaits at call sites — saves microtasks.
- BlockRenderer: lazy-load StrommixBlock (~220 KB) via dynamic import
  in the template — pages without the live-strommix widget no longer
  ship its bundle.

Also: enabled Caddy zstd+gzip on windwiderstand.de vhost (server-side
edit; verified `content-encoding: zstd` on response).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-21 12:30:15 +02:00
parent c00eb26689
commit 81c82c33e6
9 changed files with 144 additions and 91 deletions
+5 -18
View File
@@ -1,4 +1,4 @@
import { getTranslationBundleBySlug } from '$lib/cms'; import { getNormalizedTranslations } from '$lib/cms';
import { isCmsAvailable } from '$lib/cms-health.server'; import { isCmsAvailable } from '$lib/cms-health.server';
import { maintenanceResponse } from '$lib/maintenance.server'; import { maintenanceResponse } from '$lib/maintenance.server';
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants'; import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
@@ -97,23 +97,10 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
try { try {
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' }); event.locals.translations = await getNormalizedTranslations(
if (bundle?.strings && typeof bundle.strings === 'object') { TRANSLATION_BUNDLE_SLUG,
const normalized: Record<string, string> = {}; { locale: 'de' },
for (const [key, val] of Object.entries(bundle.strings)) { );
const v = val as unknown;
const str =
typeof v === 'string'
? v
: typeof v === 'object' && v !== null && 'value' in v
? (v as { value?: unknown }).value
: (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label;
if (typeof str === 'string') normalized[key] = str;
}
event.locals.translations = normalized;
} else {
event.locals.translations = {};
}
} catch (err) { } catch (err) {
event.locals.translations = {}; event.locals.translations = {};
logWarn('hooks.translations', err); logWarn('hooks.translations', err);
+31 -31
View File
@@ -326,8 +326,7 @@ export async function loadPostsList(opts: {
for (const post of posts) { for (const post of posts) {
const field = getPostImageField(post.postImage); const field = getPostImageField(post.postImage);
if (field) { if (field) {
try { const url = ensureTransformedImage(field.url, {
const url = await ensureTransformedImage(field.url, {
width: 400, width: 400,
height: 267, height: 267,
fit: "cover", fit: "cover",
@@ -343,9 +342,6 @@ export async function loadPostsList(opts: {
p._resolvedImageUrl = url; p._resolvedImageUrl = url;
p._rawImageUrl = field.url; p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null; p._imageFocal = field.focal ?? null;
} catch {
/* image optional */
}
} }
} }
} catch (e) { } catch (e) {
@@ -512,15 +508,14 @@ export async function resolvePostOverviewBlocks(
.filter(Boolean); .filter(Boolean);
const limit = const limit =
typeof item.numberItems === "number" ? item.numberItems : 9999; typeof item.numberItems === "number" ? item.numberItems : 9999;
const resolved: PostEntry[] = []; const settled = await Promise.all(
for (const slug of slugs.slice(0, limit)) { slugs.slice(0, limit).map((slug) =>
const post = await getPostBySlug(slug, { getPostBySlug(slug, { locale: "de", resolve: ["all"] }).catch(
locale: "de", () => null,
resolve: ["all"], ),
}); ),
if (post) resolved.push(post); );
} item.postsResolved = settled.filter((p): p is PostEntry => !!p);
item.postsResolved = resolved;
} }
} }
if (Array.isArray(item.postsResolved)) { if (Array.isArray(item.postsResolved)) {
@@ -531,8 +526,7 @@ export async function resolvePostOverviewBlocks(
for (const post of item.postsResolved as PostEntry[]) { for (const post of item.postsResolved as PostEntry[]) {
const field = getPostImageField(post.postImage); const field = getPostImageField(post.postImage);
if (field) { if (field) {
try { const url = ensureTransformedImage(field.url, {
const url = await ensureTransformedImage(field.url, {
width: 400, width: 400,
height: 267, height: 267,
fit: "cover", fit: "cover",
@@ -548,9 +542,6 @@ export async function resolvePostOverviewBlocks(
p._resolvedImageUrl = url; p._resolvedImageUrl = url;
p._rawImageUrl = field.url; p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null; p._imageFocal = field.focal ?? null;
} catch {
// Bild optional
}
} }
} }
} }
@@ -602,19 +593,28 @@ export async function resolveCalendarBlocks(
resolve: "items", resolve: "items",
}); });
const raw = calendar?.items ?? rawItems; const raw = calendar?.items ?? rawItems;
const resolved: CalendarItemData[] = []; const settled = await Promise.all(
for (const x of raw) { raw.map(async (x): Promise<CalendarItemData | null> => {
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) { if (
resolved.push(x as CalendarItemData); typeof x === "object" &&
} else { x !== null &&
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug; "terminZeit" in x &&
if (islug) { "title" in x
const entry = await getCalendarItemBySlug(islug, { locale: "de" }); ) {
if (entry) resolved.push(entry); return x as CalendarItemData;
} }
} const islug =
} typeof x === "string" ? x : (x as { _slug?: string })?._slug;
(item as { items?: CalendarItemData[] }).items = resolved; if (!islug) return null;
const entry = await getCalendarItemBySlug(islug, { locale: "de" }).catch(
() => null,
);
return entry ?? null;
}),
);
(item as { items?: CalendarItemData[] }).items = settled.filter(
(e): e is CalendarItemData => !!e,
);
} }
} }
} }
+35 -1
View File
@@ -95,7 +95,10 @@ async function cached<T>(
key: string, key: string,
fn: () => Promise<T>, fn: () => Promise<T>,
): Promise<T> { ): Promise<T> {
if (import.meta.env.DEV) return fn(); // Dev: Cache aus by default (sofortige Reflektion neuer CMS-Daten).
// Mit DEV_CACHE_CMS=1 anschalten — sinnvoll wenn lokal gegen Prod-CMS
// entwickelt wird, sonst werden Netzwerk-Roundtrips bei jedem Request fällig.
if (import.meta.env.DEV && process.env.DEV_CACHE_CMS !== "1") return fn();
const hit = cache.get(key); const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value as T; if (hit && hit.expires > Date.now()) return hit.value as T;
const pending = inflight.get(key) as Promise<T> | undefined; const pending = inflight.get(key) as Promise<T> | undefined;
@@ -847,6 +850,37 @@ export async function getTranslationBundleBySlug(
}); });
} }
/**
* Normalised key→string map for `event.locals.translations`. Cached on the
* raw `strings` object identity so the per-request `Object.entries` loop runs
* once per bundle reload (every 5 min) instead of every request.
*/
const normalizedTranslations = new WeakMap<object, Record<string, string>>();
export async function getNormalizedTranslations(
slug: string,
options?: { locale?: string },
): Promise<Record<string, string>> {
const bundle = await getTranslationBundleBySlug(slug, options);
const strings = bundle?.strings;
if (!strings || typeof strings !== "object") return {};
const cached = normalizedTranslations.get(strings as object);
if (cached) return cached;
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(strings as Record<string, unknown>)) {
const str =
typeof val === "string"
? val
: typeof val === "object" && val !== null && "value" in val
? (val as { value?: unknown }).value
: (val as { text?: unknown })?.text ??
(val as { label?: unknown })?.label;
if (typeof str === "string") out[key] = str;
}
normalizedTranslations.set(strings as object, out);
return out;
}
/** Kalender-Eintrag (calendar) aus OpenAPI-Spec. */ /** Kalender-Eintrag (calendar) aus OpenAPI-Spec. */
export type CalendarEntry = components["schemas"]["calendar"]; export type CalendarEntry = components["schemas"]["calendar"];
+5 -2
View File
@@ -18,7 +18,6 @@
import OpnFormBlock from "./blocks/OpnFormBlock.svelte"; import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte"; import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte";
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte"; import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import StrommixBlock from "./blocks/StrommixBlock.svelte";
import WindkarteBlock from "./blocks/WindkarteBlock.svelte"; import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
import type { import type {
@@ -93,7 +92,11 @@
{:else if type === "deadline_banner"} {:else if type === "deadline_banner"}
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} /> <DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
{:else if type === "live_strommix"} {:else if type === "live_strommix"}
<StrommixBlock block={block as LiveStrommixBlockData} /> <!-- Lazy: ~220 KB Block-Bundle nur laden wenn Seite das Widget tatsächlich hat -->
{#await import("./blocks/StrommixBlock.svelte") then m}
{@const Comp = m.default}
<Comp block={block as LiveStrommixBlockData} />
{/await}
{:else if type === "wind_map"} {:else if type === "wind_map"}
<WindkarteBlock block={block as WindMapBlockData} /> <WindkarteBlock block={block as WindMapBlockData} />
{:else} {:else}
+1 -1
View File
@@ -108,7 +108,7 @@ export function getTransformUrl(url: string, params: RustyImageTransformParams =
} }
/** Alias für Kompatibilität (z. B. rustyastro / Layout). */ /** Alias für Kompatibilität (z. B. rustyastro / Layout). */
export async function ensureTransformedImage(url: string, params: RustyImageTransformParams = {}): Promise<string> { export function ensureTransformedImage(url: string, params: RustyImageTransformParams = {}): string {
return getCmsImageUrl(url, params); return getCmsImageUrl(url, params);
} }
+23 -1
View File
@@ -62,8 +62,30 @@ const HTML_BLOCK_OPTIONS: sanitizeHtml.IOptions = {
}, },
}; };
/**
* Pro-Block-Hash-Cache: sanitize-html ist teuer (~ms pro KB), das CMS
* liefert dasselbe Markup wieder und wieder. Key = raw input, Value =
* sanitised output. Größe geclippt (LRU-ähnlich via Map-Insertion-Order).
*/
const SANITIZE_CACHE = new Map<string, string>();
const SANITIZE_CACHE_MAX = 500;
export function sanitizeUntrustedHtml(html: string): string { export function sanitizeUntrustedHtml(html: string): string {
return sanitizeHtml(html, HTML_BLOCK_OPTIONS); const cached = SANITIZE_CACHE.get(html);
if (cached !== undefined) {
// Move to end (most-recently-used).
SANITIZE_CACHE.delete(html);
SANITIZE_CACHE.set(html, cached);
return cached;
}
const out = sanitizeHtml(html, HTML_BLOCK_OPTIONS);
SANITIZE_CACHE.set(html, out);
if (SANITIZE_CACHE.size > SANITIZE_CACHE_MAX) {
// Evict oldest.
const oldest = SANITIZE_CACHE.keys().next().value;
if (oldest !== undefined) SANITIZE_CACHE.delete(oldest);
}
return out;
} }
type Block = { _type?: string; html?: unknown } & Record<string, unknown>; type Block = { _type?: string; html?: unknown } & Record<string, unknown>;
+2 -2
View File
@@ -293,7 +293,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
} }
} catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); } } catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); }
} else { } else {
logoUrl = await ensureTransformedImage(u, { logoUrl = ensureTransformedImage(u, {
height: 56, height: 56,
fit: 'contain', fit: 'contain',
format: 'webp', format: 'webp',
@@ -313,7 +313,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
try { try {
const isSvgLogo = logoUrl?.split('?')[0].toLowerCase().endsWith('.svg'); const isSvgLogo = logoUrl?.split('?')[0].toLowerCase().endsWith('.svg');
const source = (logoUrl && !isSvgLogo) ? logoUrl : DEFAULT_SOCIAL_IMAGE_URL; const source = (logoUrl && !isSvgLogo) ? logoUrl : DEFAULT_SOCIAL_IMAGE_URL;
const transformed = await ensureTransformedImage(source, SOCIAL_IMAGE_TRANSFORM); const transformed = ensureTransformedImage(source, SOCIAL_IMAGE_TRANSFORM);
logoSocialImage = transformed.startsWith('/') logoSocialImage = transformed.startsWith('/')
? `${siteBaseUrl}${transformed}` ? `${siteBaseUrl}${transformed}`
: transformed; : transformed;
+10 -3
View File
@@ -51,11 +51,18 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
error(404, 'Seite nicht gefunden'); error(404, 'Seite nicht gefunden');
} }
// resolveContentImages baut nur URLs zusammen (kein I/O) und mutiert den
// Tree — vor den Block-Resolvern erledigen, die danach gegen mutierte
// Blocks arbeiten.
await resolveContentImages(page); await resolveContentImages(page);
// PostOverview liefert tagsMap, den SearchableText braucht. Calendar +
// DeadlineBanner sind unabhängig → parallel mit SearchableText laufen.
const tagsMap = await resolvePostOverviewBlocks(page); const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap); await Promise.all([
await resolveCalendarBlocks(page); resolveSearchableTextBlocks(page, tagsMap),
await resolveDeadlineBannerBlocks(page); resolveCalendarBlocks(page),
resolveDeadlineBannerBlocks(page),
]);
sanitizeBlocks(page); sanitizeBlocks(page);
// Resolve top banner if present // Resolve top banner if present
+2 -2
View File
@@ -55,7 +55,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
const postImageField = getPostImageField(post.postImage); const postImageField = getPostImageField(post.postImage);
const postImageUrl = postImageField const postImageUrl = postImageField
? await ensureTransformedImage(postImageField.url, { ? ensureTransformedImage(postImageField.url, {
width: 150, width: 150,
height: 200, height: 200,
fit: 'cover', fit: 'cover',
@@ -67,7 +67,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, ''); const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, '');
const canonical = `${siteBaseUrl}/post/${slug}/`; const canonical = `${siteBaseUrl}/post/${slug}/`;
const postSocialImageRel = postImageField const postSocialImageRel = postImageField
? await ensureTransformedImage(postImageField.url, { ? ensureTransformedImage(postImageField.url, {
...POST_SOCIAL_IMAGE_TRANSFORM, ...POST_SOCIAL_IMAGE_TRANSFORM,
focalX: postImageField.focal?.x, focalX: postImageField.focal?.x,
focalY: postImageField.focal?.y, focalY: postImageField.focal?.y,