perf(cms): batch layout bootstrap, sparse fieldsets, depth caps
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m16s

Adopt RustyCMS' new resolve semantics (_depth, _fields,
POST /_batch) where they pay off:

- +layout.server.ts: 4 sequential GETs (navHeader, navSocial,
  footer, page_config) collapsed into one batchFetch() call.
  Legacy "default" page_config slug stays as fallback Single-
  Batch only when the canonical slug is missing.
- New getPageStubs() / getPostStubs() use _fields= so the
  Slug→Label/Slug→URL maps don't pull body/rows. Typically
  10-50× smaller payload, eligible for the same TTL cache.
- loadPostsList() now requests posts with _depth=2 and a
  sparse fieldset that keeps card-level fields plus
  postImage.* and postTag.{name,icon,color,_slug}. Body
  Markdown stays out of the listing entirely.
- Page + post detail routes set _depth=3 as a cycle/blast-radius
  cap (page → block → image → img reaches in three levels).
- cms.ts: ContentGetOptions gains depth/fields, getPageBySlug /
  getPostBySlug / getPosts thread them through (cache key
  extended). New batchFetch() / batchData<T>() helpers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-30 22:00:10 +02:00
parent 35f7aa8f90
commit 3bc7273e71
5 changed files with 260 additions and 38 deletions
+24 -1
View File
@@ -255,8 +255,31 @@ export async function loadPostsList(opts: {
let cmsError: string | null = null;
try {
// Sparse fieldset für die Listen-Ansicht: Body/Rows brauchen wir hier
// nicht, nur Karten-Felder + image + tag-Metadaten. _depth=2 reicht
// (post → postImage → img-Sidecar; post → postTag → name/icon/color).
[posts, tags] = await Promise.all([
getPosts({ resolve: ["postImage"] }),
getPosts({
resolve: ["postImage", "postTag"],
depth: 2,
fields: [
"_slug",
"slug",
"headline",
"linkName",
"subheadline",
"excerpt",
"created",
"isEvent",
"eventDate",
"hideFromListing",
"postImage.*",
"postTag.name",
"postTag.icon",
"postTag.color",
"postTag._slug",
],
}),
getTags(),
]);
posts = filterHiddenPosts(sortPostsByDate(posts));
+137 -4
View File
@@ -229,10 +229,74 @@ export type ContentGetOptions = {
locale?: string;
/** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */
resolve?: string[];
/** Cap für Resolve-Rekursionstiefe (0..=10). Ohne Wert: legacy unbegrenzt. */
depth?: number;
/** Sparse-Fieldset, z. B. ["title", "author.name"]. Punktnotation triggert implicit _resolve. */
fields?: string[];
/** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */
preview?: string;
};
// ---------------------------------------------------------------------------
// Batch endpoint (POST /api/content/_batch)
// ---------------------------------------------------------------------------
/** Eine Sub-Anfrage im Batch. `id` wird im Response als Schlüssel zurückgegeben. */
export type BatchRequest = {
id: string;
collection: string;
slug: string;
environment?: string;
locale?: string;
resolve?: string;
depth?: number;
fields?: string[];
preview?: string;
raw_assets?: boolean;
};
/** Pro-Sub-Result: Erfolg (`status: 200, data`) oder Fehler (`status: 4xx/5xx, error`). */
export type BatchResult<T = unknown> =
| { status: 200; data: T }
| { status: number; error: string };
/** Antwort von POST /api/content/_batch. */
export type BatchResponse = { results: Record<string, BatchResult> };
/**
* Schickt mehrere Lookups in einem HTTP-Roundtrip an den CMS-Batch-Endpoint.
*
* - Outer-Status ist immer 200 wenn der Envelope ok ist; pro Sub-Request
* findet sich der eigentliche Status in `results.<id>.status`.
* - Cap pro Call wird vom Server erzwungen (`RUSTYCMS_BATCH_MAX_REQUESTS`,
* default 50). Über dem Cap → 400 mit Detail-Message.
* - Kein TTL-Cache: Batch-Calls sind POST und tragen einen serialisierten
* Body, der sich pro Call unterscheidet. Wer Cache will, soll die
* Single-GET-Funktionen nehmen.
*/
export async function batchFetch(requests: BatchRequest[]): Promise<BatchResponse> {
if (requests.length === 0) return { results: {} };
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/_batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requests }),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(
`RustyCMS batch failed: ${res.status} ${res.statusText}. ${detail}`,
);
}
return (await res.json()) as BatchResponse;
}
/** Type-narrowing-Helper: liefert `data`, wenn der Sub-Request 200 lieferte, sonst `null`. */
export function batchData<T>(result: BatchResult | undefined): T | null {
if (!result) return null;
return result.status === 200 ? (result as { data: T }).data : null;
}
/** Führenden + abschließenden Slash entfernen (CMS "slug": "/about"; Route-Param mit trailing slash). */
function normalizePageSlug(s: string | undefined): string {
return (s ?? "").replace(/^\/+|\/+$/g, "").trim();
@@ -246,7 +310,9 @@ export async function getPageBySlug(
options?: ContentGetOptions,
): Promise<PageEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
const fieldsKey = options?.fields?.join(",") ?? "";
const depthKey = options?.depth ?? "";
const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`;
const fetchFn = async () => {
const base = getBaseUrl();
const trySlug = async (s: string): Promise<Response> => {
@@ -254,6 +320,10 @@ export async function getPageBySlug(
if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(","));
if (typeof options?.depth === "number")
u.searchParams.set("_depth", String(options.depth));
if (options?.fields?.length)
u.searchParams.set("_fields", options.fields.join(","));
if (options?.preview) u.searchParams.set("preview", options.preview);
return fetch(u.toString());
};
@@ -291,6 +361,55 @@ export async function getPageSlugs(): Promise<string[]> {
return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean);
}
/** Magere Page-Stub-Daten für Navigation/Slug-Maps — nur Label-Felder, kein Body. */
export type PageStub = Pick<
PageEntry,
"_slug" | "slug" | "linkName" | "name" | "headline"
>;
/**
* Page-Liste auf nur die Felder reduziert, die für Slug→Label/Slug→URL-Maps
* gebraucht werden. Spart Body/Rows — typisch Faktor 10-50× kleiner.
*/
export async function getPageStubs(options?: { locale?: string }): Promise<PageStub[]> {
const locale = options?.locale ?? "";
const key = `page:stubs:${locale}`;
return cached("page", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/page`);
url.searchParams.set("_fields", "_slug,slug,linkName,name,headline");
url.searchParams.set("_per_page", "1000");
if (locale) url.searchParams.set("_locale", locale);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`);
const data = (await res.json()) as { items?: PageStub[] };
return data.items ?? [];
});
}
/** Magere Post-Stubs für Navigation/Slug-Maps — kein Body, keine Rows. */
export type PostStub = Pick<
PostEntry,
"_slug" | "slug" | "linkName" | "headline"
>;
/** Post-Liste reduziert auf Slug+Label-Felder. */
export async function getPostStubs(options?: { locale?: string }): Promise<PostStub[]> {
const locale = options?.locale ?? "";
const key = `post:stubs:${locale}`;
return cached("post", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/post`);
url.searchParams.set("_fields", "_slug,slug,linkName,headline");
url.searchParams.set("_per_page", "1000");
if (locale) url.searchParams.set("_locale", locale);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`);
const data = (await res.json()) as { items?: PostStub[] };
return data.items ?? [];
});
}
/** Navigation (Single Source of Truth: RustyCMS OpenAPI). */
export type NavigationEntry = components["schemas"]["navigation"];
@@ -439,7 +558,9 @@ export async function getPostBySlug(
options?: ContentGetOptions,
): Promise<PostEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
const fieldsKey = options?.fields?.join(",") ?? "";
const depthKey = options?.depth ?? "";
const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`;
const fetchFn = async () => {
const base = getBaseUrl();
const trySlug = async (s: string): Promise<Response> => {
@@ -447,6 +568,10 @@ export async function getPostBySlug(
if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(","));
if (typeof options?.depth === "number")
u.searchParams.set("_depth", String(options.depth));
if (options?.fields?.length)
u.searchParams.set("_fields", options.fields.join(","));
if (options?.preview) u.searchParams.set("preview", options.preview);
return fetch(u.toString());
};
@@ -484,12 +609,16 @@ export async function getPostSlugs(): Promise<string[]> {
return items.map((p) => normalizePostSlug(p.slug ?? p._slug)).filter(Boolean);
}
/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve). */
/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve, _depth, _fields). */
export interface GetPostsOptions {
_sort?: string;
_order?: "asc" | "desc";
/** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */
resolve?: string | string[];
/** Cap für Resolve-Rekursionstiefe (0..=10). */
depth?: number;
/** Sparse-Fieldset, z. B. ["headline", "excerpt", "postImage.*", "postTag.name"]. */
fields?: string[];
}
/** Alle Post-Einträge (GET /api/content/post). Gecacht pro Request. */
@@ -500,7 +629,9 @@ export async function getPosts(
const resolveVal = Array.isArray(opts.resolve)
? opts.resolve.join(",")
: (opts.resolve ?? "");
const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
const fieldsVal = opts.fields?.join(",") ?? "";
const depthVal = opts.depth ?? "";
const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}:${depthVal}:${fieldsVal}`;
return cached("post", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/post`);
@@ -508,6 +639,8 @@ export async function getPosts(
if (opts._sort) url.searchParams.set("_sort", opts._sort);
if (opts._order) url.searchParams.set("_order", opts._order);
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth));
if (fieldsVal) url.searchParams.set("_fields", fieldsVal);
const res = await fetch(url.toString());
if (!res.ok) throw new Error("RustyCMS list post failed");
const data = (await res.json()) as { items?: PostEntry[] };
+94 -33
View File
@@ -3,14 +3,19 @@ import type { LayoutServerLoad } from './$types';
export const trailingSlash = 'always';
import {
batchFetch,
batchData,
getNavigationByKey,
NavigationKeys,
getPages,
getPosts,
getFooterBySlug,
getPageConfigBySlug,
getPageStubs,
getPostStubs,
} from '$lib/cms';
import type {
NavLinkEntry,
NavigationEntry,
FooterEntry,
PageConfigEntry,
} from '$lib/cms';
import type { NavLinkEntry } from '$lib/cms';
import { ensureTransformedImage } from '$lib/rusty-image';
import {
DEFAULT_SOCIAL_IMAGE_URL,
@@ -45,15 +50,82 @@ function getSlugFromEntry(entry: NavLinkEntry): string {
export const load: LayoutServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
// Header: Navigation laden
// Layout-Bootstrap: Navigation (header + social), Footer, page_config in
// einem einzigen HTTP-Roundtrip (POST /api/content/_batch). Reduziert von
// 4 sequentiellen Calls auf 1. Page-/Post-Stubs gehen separat (sparse
// _fields), weil sie viel größere Listen sind und einen eigenen TTL-Cache
// verdienen.
let bootstrapNavHeader: NavigationEntry | null = null;
let bootstrapNavSocial: NavigationEntry | null = null;
let bootstrapFooter: FooterEntry | null = null;
let bootstrapConfig: PageConfigEntry | null = null;
try {
const batch = await batchFetch([
{
id: 'navHeader',
collection: 'navigation',
slug: NavigationKeys.header,
locale: 'de',
resolve: 'links',
},
{
id: 'navSocial',
collection: 'navigation',
slug: NavigationKeys.socialMedia,
locale: 'de',
resolve: 'all',
},
{
id: 'footer',
collection: 'footer',
slug: 'footer-main',
locale: 'de',
resolve: 'all',
},
{
id: 'config',
collection: 'page_config',
slug: 'page-config-default',
locale: 'de',
resolve: 'logo',
},
]);
bootstrapNavHeader = batchData<NavigationEntry>(batch.results.navHeader);
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
} catch {
/* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */
}
// Fallback für config: Slug "default" probieren (legacy), wenn die kanonische
// Variante fehlt. Selten genug für einen weiteren Single-Call.
if (!bootstrapConfig) {
try {
const fallback = await batchFetch([
{
id: 'config',
collection: 'page_config',
slug: 'default',
locale: 'de',
resolve: 'logo',
},
]);
bootstrapConfig = batchData<PageConfigEntry>(fallback.results.config);
} catch {
/* optional */
}
}
// Header: Navigation aufbereiten (slug→label/href-Maps aus Page-/Post-Stubs)
let headerLinks: NavLink[] = [];
try {
const nav = await getNavigationByKey(NavigationKeys.header, {
locale: 'de',
resolve: 'links',
});
const nav = bootstrapNavHeader;
const rawLinks = nav?.links ?? [];
const [pages, posts] = await Promise.all([getPages(), getPosts()]);
const [pages, posts] = await Promise.all([
getPageStubs({ locale: 'de' }),
getPostStubs({ locale: 'de' }),
]);
const slugToLabel = new Map<string, string>();
const slugToPageHref = new Map<string, string>();
for (const p of pages) {
@@ -123,13 +195,16 @@ export const load: LayoutServerLoad = async ({ locals }) => {
// CMS nicht erreichbar
}
// Social-Media-Links
// Social-Media-Links (aus Batch übernommen — Fallback falls Batch nichts
// gegeben hat: alter Single-GET mit eigenem TTL-Cache).
let socialLinks: SocialLink[] = [];
try {
const socialNav = await getNavigationByKey(NavigationKeys.socialMedia, {
locale: 'de',
resolve: 'all',
});
const socialNav =
bootstrapNavSocial ??
(await getNavigationByKey(NavigationKeys.socialMedia, {
locale: 'de',
resolve: 'all',
}));
const rawSocialLinks = socialNav?.links ?? [];
for (const entry of rawSocialLinks) {
const asObj =
@@ -149,28 +224,14 @@ export const load: LayoutServerLoad = async ({ locals }) => {
/* Social-Links optional */
}
// Footer laden (mit Row-Resolve)
let footerData = null;
try {
footerData = await getFooterBySlug('footer-main', {
locale: 'de',
resolve: ['all'],
});
} catch {
// CMS nicht erreichbar
}
// Footer + page_config kommen direkt aus dem Batch (siehe oben).
const footerData = bootstrapFooter;
// Logo und SEO-Templates aus page_config
let logoUrl: string | null = null;
let logoSvgHtml: string | null = null;
let pageConfig = null;
const pageConfig = bootstrapConfig;
try {
pageConfig =
(await getPageConfigBySlug('page-config-default', {
locale: 'de',
resolve: 'logo',
})) ??
(await getPageConfigBySlug('default', { locale: 'de', resolve: 'logo' }));
const rawLogo = pageConfig?.logo;
const rawSrc =
typeof rawLogo === 'string'
+3
View File
@@ -38,6 +38,9 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
const page = await getPageBySlug(slug, {
locale: 'de',
resolve: PAGE_RESOLVE,
// Hard-cap gegen Cycle-Surprises (page → image → img-Asset reicht in 3
// Levels). Kein Body-Stripping hier — Page-Detail braucht alle Felder.
depth: 3,
preview,
});
+2
View File
@@ -35,6 +35,8 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
const post = await getPostBySlug(slug, {
locale: 'de',
resolve: POST_RESOLVE,
// Hard-cap: post → block → image → img reicht in 3 Levels.
depth: 3,
preview,
});