perf(cms): batch layout bootstrap, sparse fieldsets, depth caps
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:
+24
-1
@@ -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
@@ -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[] };
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user