perf(cms-client+ssr): O(1)-Slug-Lookup, Sparse-Pages, höhere TTLs, Cache-Control, Parallel-Resolver
- getPageBySlug: neuer Filter-Lookup-Pfad (?slug=X&_fields=_slug) als primärer Fallback — O(1) statt komplette Page-Liste laden. Vollständige Liste nur noch als Last-Resort-Safety-Net. Skaliert egal wieviele Pages. - getPages: Sparse-Projektion via _fields (8 Felder statt Voll-Resolve- Tree) → ~90 % kleinere Payload pro Call. - CACHE_TTL hoch (Webhooks purgen eh): page/page_config/navigation/footer/ fullwidth_banner/translation/openapi → 1h; tag/text_fragment → 30min; post → 10min. Spart Cold-Loads + CMS-Roundtrips. - [...slug]/+page.server.ts: Cache-Control public, s-maxage=60, stale-while-revalidate=600 für öffentliche Pages (Caddy/Browser cachen). Preview-Branch bleibt private/no-store. - Resolver parallelisiert: Calendar/Deadline/StellingnahmeGenerator starten sofort parallel zu PostOverview, nicht erst danach. - Icon-Subset um clock-remove-outline ergänzt (Frist-abgelaufen-Banner). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+66
-15
@@ -62,20 +62,22 @@ let openApiCache: OpenApiSpec | null = null;
|
||||
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
|
||||
*/
|
||||
const CACHE_TTL: Record<string, number> = {
|
||||
page: 5 * 60_000,
|
||||
page_config: 10 * 60_000,
|
||||
navigation: 10 * 60_000,
|
||||
footer: 10 * 60_000,
|
||||
post: 60_000,
|
||||
tag: 5 * 60_000,
|
||||
text_fragment: 5 * 60_000,
|
||||
fullwidth_banner: 5 * 60_000,
|
||||
calendar: 60_000,
|
||||
calendar_item: 60_000,
|
||||
translation: 5 * 60_000,
|
||||
translation_bundle: 5 * 60_000,
|
||||
openapi: 10 * 60_000,
|
||||
default: 60_000,
|
||||
// Webhooks purgen bei Mutation → höhere TTL ist unkritisch und spart
|
||||
// Cold-Loads + CMS-Roundtrips.
|
||||
page: 60 * 60_000, // 1h (war 5min)
|
||||
page_config: 60 * 60_000, // 1h
|
||||
navigation: 60 * 60_000, // 1h
|
||||
footer: 60 * 60_000, // 1h
|
||||
post: 10 * 60_000, // 10min (war 1min) — Posts ändern öfter
|
||||
tag: 30 * 60_000, // 30min
|
||||
text_fragment: 30 * 60_000, // 30min
|
||||
fullwidth_banner: 60 * 60_000, // 1h
|
||||
calendar: 5 * 60_000, // 5min — wenig Mutationen, aber zeitsensitiv
|
||||
calendar_item: 5 * 60_000, // 5min
|
||||
translation: 60 * 60_000, // 1h
|
||||
translation_bundle: 60 * 60_000, // 1h
|
||||
openapi: 60 * 60_000, // 1h
|
||||
default: 5 * 60_000,
|
||||
};
|
||||
|
||||
type CacheEntry<T> = { value: T; expires: number };
|
||||
@@ -167,12 +169,17 @@ export async function getPages(): Promise<PageEntry[]> {
|
||||
// Ohne vollständige Liste scheitert der Slug→_slug-Fallback in
|
||||
// getPageBySlug für Seiten jenseits der ersten Page → 404.
|
||||
const PER_PAGE = 200;
|
||||
// Sparse-Projektion: nur die Felder, die Caller (sitemap, search-index,
|
||||
// root-page, getPageBySlug-Fallback) tatsächlich verwenden. Spart 80–95 %
|
||||
// Payload-Größe und vermeidet komplette Resolve-Trees.
|
||||
const FIELDS =
|
||||
"_slug,_updated,_created,slug,linkName,name,headline,subheadline";
|
||||
const all: PageEntry[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
const res = await cmsFetch(
|
||||
`${base}/api/content/page?_per_page=${PER_PAGE}&_page=${page}`,
|
||||
`${base}/api/content/page?_per_page=${PER_PAGE}&_page=${page}&_fields=${FIELDS}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -341,6 +348,38 @@ function normalizePageSlug(s: string | undefined): string {
|
||||
return (s ?? "").replace(/^\/+|\/+$/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schlanker Lookup: gibt zu einem URL-Slug ("/regionalplan/w36" oder
|
||||
* "regionalplan/w36") den internen `_slug` zurück, indem das CMS gezielt nach
|
||||
* dem `slug`-Feld gefiltert wird. Nur `_slug` wird projiziert → kleine Payload
|
||||
* unabhängig von Collection-Größe. Probiert beide Slug-Schreibweisen.
|
||||
*/
|
||||
async function findPageSlugByField(
|
||||
normSlug: string,
|
||||
base: string,
|
||||
): Promise<string | null> {
|
||||
const candidates = [normSlug, "/" + normSlug];
|
||||
for (const candidate of candidates) {
|
||||
const u = new URL(`${base}/api/content/page`);
|
||||
u.searchParams.set("slug", candidate);
|
||||
u.searchParams.set("_fields", "_slug");
|
||||
u.searchParams.set("_per_page", "2");
|
||||
let r: Response;
|
||||
try {
|
||||
r = await cmsFetch(u.toString());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) continue;
|
||||
const data = (await r.json().catch(() => null)) as
|
||||
| { items?: Array<{ _slug?: string }> }
|
||||
| null;
|
||||
const hit = data?.items?.[0]?._slug;
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
|
||||
*/
|
||||
@@ -373,6 +412,18 @@ export async function getPageBySlug(
|
||||
res = await trySlug("/" + normSlug);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
// O(1)-Lookup via slug-Feld-Filter (statt komplette Page-Liste zu holen).
|
||||
// Skaliert egal wie viele Pages existieren. Beide Slug-Schreibweisen
|
||||
// probieren — Filter macht exakten String-Vergleich.
|
||||
const matchSlug = await findPageSlugByField(normSlug, base);
|
||||
if (matchSlug) {
|
||||
res = await trySlug(matchSlug);
|
||||
}
|
||||
}
|
||||
if (res.status === 404) {
|
||||
// Last-resort: vollständige Liste durchsuchen (Safety-Net für Edge-Cases
|
||||
// — z.B. Slug-Formen die der Exakt-Filter verfehlt aber post-Normalisierung
|
||||
// matchen würden). Sollte praktisch nie greifen.
|
||||
const items = await getPages();
|
||||
const match = items.find(
|
||||
(p) =>
|
||||
|
||||
@@ -117,6 +117,9 @@
|
||||
"clock-alert-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M11 7v6l5.2 3.1l.8-1.2l-4.5-2.7V7zm9 5v6h2v-6zm0 8v2h2v-2zm-2 0c-1.7 1.3-3.7 2-6 2c-5.5 0-10-4.5-10-10S6.5 2 12 2c4.8 0 8.9 3.4 9.8 8h-2.1c-.9-3.4-4-6-7.7-6c-4.4 0-8 3.6-8 8s3.6 8 8 8c2.4 0 4.5-1.1 6-2.7z\"/>"
|
||||
},
|
||||
"clock-remove-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M14.47 15.08L11 13V7h1.5v5.25l3.08 1.83c-.41.28-.79.62-1.11 1m-1.39 4.84c-.36.05-.71.08-1.08.08c-4.42 0-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8c0 .37-.03.72-.08 1.08c.69.1 1.33.32 1.92.64c.1-.56.16-1.13.16-1.72c0-5.5-4.5-10-10-10S2 6.5 2 12s4.47 10 10 10c.59 0 1.16-.06 1.72-.16c-.32-.59-.54-1.23-.64-1.92m8.04-4.46L19 17.59l-2.12-2.12l-1.41 1.41L17.59 19l-2.12 2.12l1.41 1.42L19 20.41l2.12 2.13l1.42-1.42L20.41 19l2.13-2.12z\"/>"
|
||||
},
|
||||
"content-save-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V7zm2 16H5V5h11.17L19 7.83zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3M6 6h9v4H6z\"/>"
|
||||
},
|
||||
|
||||
@@ -38,6 +38,14 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
|
||||
'cache-control': 'private, no-store, max-age=0',
|
||||
'x-robots-tag': 'noindex, nofollow',
|
||||
});
|
||||
} else {
|
||||
// Public Pages: Caddy/CDN + Browser cachen 60s, stale-while-revalidate
|
||||
// erlaubt 10min Sofort-Lieferung während im Hintergrund neu geholt wird.
|
||||
// CMS-Mutationen lösen Webhook → Frontend-Content-Cache-Purge aus, der
|
||||
// Edge-Cache läuft per TTL aus (kurze Inkonsistenz akzeptabel).
|
||||
setHeaders({
|
||||
'cache-control': 'public, s-maxage=60, stale-while-revalidate=600',
|
||||
});
|
||||
}
|
||||
|
||||
const page = await getPageBySlug(slug, {
|
||||
@@ -58,15 +66,20 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
|
||||
// Tree — vor den Block-Resolvern erledigen, die danach gegen mutierte
|
||||
// Blocks arbeiten.
|
||||
await resolveContentImages(page);
|
||||
// PostOverview liefert tagsMap, den SearchableText braucht. Calendar +
|
||||
// DeadlineBanner sind unabhängig → parallel mit SearchableText laufen.
|
||||
const tagsMap = await resolvePostOverviewBlocks(page);
|
||||
await Promise.all([
|
||||
resolveSearchableTextBlocks(page, tagsMap),
|
||||
// PostOverview→SearchableText hat Daten-Abhängigkeit (tagsMap). Calendar,
|
||||
// DeadlineBanner und StellingnahmeGenerator sind unabhängig — sofort
|
||||
// parallel starten, nicht erst nach PostOverview warten.
|
||||
const postOverviewPromise = resolvePostOverviewBlocks(page);
|
||||
const independentResolvers = Promise.all([
|
||||
resolveCalendarBlocks(page),
|
||||
resolveDeadlineBannerBlocks(page),
|
||||
resolveStellingnahmeGeneratorBlocks(page),
|
||||
]);
|
||||
const tagsMap = await postOverviewPromise;
|
||||
await Promise.all([
|
||||
resolveSearchableTextBlocks(page, tagsMap),
|
||||
independentResolvers,
|
||||
]);
|
||||
sanitizeBlocks(page);
|
||||
|
||||
// Top-Bild: bevorzugt neues inline-Feld `topImage`, sonst Legacy via
|
||||
|
||||
Reference in New Issue
Block a user