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) =>
|
||||
|
||||
Reference in New Issue
Block a user