5 Min. Lesezeit

Das CMS einmal abfragen, nicht einmal pro Seite

Diese Seite hat Strapi denselben Inhalt für jede erzeugte Seite abgefragt. Jetzt holt ein Loader jede Quelle einmal pro Build, und jede Seite liest einen lokalen Store. Was sich geändert hat, was dadurch wegfiel, und die drei Fallen.

AstroStrapiTypeScript

Jede Seite hier wird vorgerendert. Zur Anfragezeit gibt es keinen Server, also passiert jeder Zugriff auf das CMS während des Builds — das war immer so und bleibt so. Falsch war, wie oft es passierte.

Das gemeinsame Layout rendert einen Footer, der mein Profil braucht. Also wurde das Profil einmal pro Seite geholt. Nicht einmal pro Build: einmal pro Seite. Diese Seite baut 136 Seiten, ein Content-Type, der sich vielleicht zweimal im Jahr ändert, wurde also pro Deploy 136 Mal aus Strapi gezogen. Projekte holte der Projektindex, dann noch einmal jede Detailseite, dann noch einmal der Suchindex. Zwei Features hatten sich handgeschriebene Promise-Caches zugelegt, einzig um das zu übertünchen.

flowchart LR
  p1["/ "] --> d1["data.ts"]
  p2["/projekte"] --> d2["data.ts"]
  p3["…134 weitere Seiten"] --> d3["data.ts"]
  d1 --> cms[("Strapi")]
  d2 --> cms
  d3 --> cms

Ein Loader pro Quelle

Astros Content Layer dreht das um. Statt dass eine Seite beim Rendern Daten zieht, deklariert man eine Collection mit einem Loader, und dieser Loader läuft einmal pro Sync — bevor irgendetwas rendert — und schreibt seine Ergebnisse in einen lokalen Content-Store. Die Seiten lesen dann diesen Store.

Es gibt jetzt vierzehn Collections, eine pro CMS-Quelle. Die ganze content.config.ts ist ein Manifest; das Abrufen bleibt in den Modulen, die es immer schon besaßen.

flowchart TD
  subgraph sync["Sync — einmal pro Build"]
    loader["Loader"] --> api["api.ts"]
    api --> cms[("Strapi")]
    loader --> store[("Content-Store")]
  end
  subgraph render["Rendern — einmal pro Seite"]
    pages["136 Seiten"] --> store
  end

Die Zugriffsfunktionen haben Namen und Signaturen behalten, deshalb wurde keine einzige Seite und keine einzige Komponente angefasst. getProfile(locale) liefert weiterhin ein Profil — sie liest nun einen Eintrag statt eine Anfrage zu stellen.

Die Eintrags-IDs tragen die Locale: en/abc123, de/abc123. Ein Verbraucher wählt eine Locale per Präfixfilter, und weil die ID die Locale plus den Schlüssel ist, gibt es kein separates Locale-Feld, das ihr widersprechen könnte. Single Types lassen den Schlüssel weg und nutzen ein schlichtes en / de, was das Lesen zu einem direkten Zugriff statt einer Suche macht.

Ein Detail, das kleinlich aussieht und es nicht ist: jeder Eintrag speichert ein explizites order-Feld, übernommen aus seiner Position in der bereits sortierten API-Antwort. Die Iterationsreihenfolge des Stores ist ein Implementierungsdetail, und sich darauf zu verlassen hieße, dass die von Strapi angeforderte Sortierung die Reise stillschweigend nicht mehr überlebt.

Drei Dinge, die verschwanden

Das Interessante an dieser Migration ist, wie viel Code sie gelöscht statt hinzugefügt hat.

Die Caches sind weg. Beide handgeschriebenen Promise-Caches existierten nur, um wiederholte Abrufe zu verhindern. Mit dem Store gibt es nichts zu memoisieren — also weg damit, samt der Kommentare, die erklärten, warum sie nötig waren.

Die doppelten gefilterten Abfragen sind weg. „Gib mir nur die hervorgehobenen Projekte" war eine zweite Anfrage mit Filter. Jetzt ist es ein .filter() über eine Menge, die der Loader schon hat. Dasselbe für Skill-Tags, dasselbe für die Zeitleisten-Highlights auf der Startseite.

Der Übersetzungs-Fallback wurde ehrlich. Ein Dokument ohne Zeile für eine Locale bedeutete früher einen 404 von Strapi, nach Fehlerklasse abgefangen, mit Rückfall auf die Standard-Locale. Jetzt bedeutet es: unter de/abc123 gibt es keinen Eintrag. Fehlende Daten als Fehlen ausgedrückt, nicht als Ausnahme:

const localized = await getEntry('projects', `${locale}/${canonical.documentId}`);
return localized?.data ?? canonical;

Drei Fallen

astro check braucht jetzt ein erreichbares CMS, und das hat mich überrascht. Die Typprüfung hängt nicht von den Daten ab — getCollection('projects') wird aus dem Schema der Collection typisiert, nicht aus den Zeilen. Aber astro check führt zuerst einen Sync aus, und ein Sync macht zwei Dinge gleichzeitig: er erzeugt die Content-Typen, wofür kein CMS nötig ist, und er führt jeden Loader aus, wofür eines nötig ist. Es gibt keine Möglichkeit, nur das Erste anzufordern.

flowchart LR
  check["astro check"] --> sync["astro sync"]
  sync --> types["Typen erzeugen<br/>(kein CMS nötig)"]
  sync --> loaders["Loader ausführen<br/>(CMS nötig)"]
  types --> tsc["Typprüfung"]

Die CI hält deshalb jetzt echte Lese-Zugangsdaten. Derselbe Mechanismus erklärt ein kleineres Ärgernis: in der Entwicklung braucht eine CMS-Änderung einen Neustart, kein Neuladen, weil Loader beim Sync laufen.

TypeScript gibt einem Interface keine Index-Signatur. Astros parseData ist als TData extends Record<string, unknown> typisiert, und TypeScript gewährt implizite Index-Signaturen für Mapped Types und Aliase auf Objekt-Literale — aber nie für ein Interface, und auch nicht über einen bloßen Alias darauf. Die Hälfte meiner Content-Typen sind Interfaces. Die verlockende Lösung ist, jeden als { [K in keyof T]: T[K] } neu zu schreiben, was funktioniert und nichts verliert. Die richtige Lösung war, eine generische Einschränkung nicht länger die Form meiner Domänentypen bestimmen zu lassen: die Einschränkung aus meinen eigenen Factories nehmen und einmal an der Grenze casten.

astro:content ist serverseitig, und das ist ein Leck, das nur auf eine Gelegenheit wartet. Dieses Repo hatte schon eines: eine Chart-Insel importierte ein Modul, das den Strapi-Client erreichte und dadurch ein serverseitiges virtuelles Modul; die Insel scheiterte beim Hydrieren mit einer 500. Der Kommentar, der den Vorfall festhält, steht noch in der Datei. Diese Migration hat dieselbe Gefahr auf neuem Weg wiederhergestellt, denn jedes Datenmodul importiert jetzt astro:content.

Nur-Typ-Importe werden beim Build entfernt und sind daher unbedenklich — der einzige Grund, warum die bestehende Grenze hielt. Das ist viel zu subtil, um es dem Gedächtnis zu überlassen: es gibt jetzt einen Test, der den Import-Graphen von jedem Client-Einstiegspunkt aus durchläuft und bei jedem Laufzeit-Import eines serverseitigen Moduls fehlschlägt. Seine erste Fassung war grün, während ein echter Verstoß im Baum lag, weil sie nur direkte Importe einer einzigen Dateiendung prüfte. Ein Wächter, den man nie scheitern gesehen hat, ist kein Wächter.

Wo es gelandet ist

Vierzehn Loader, jeder genau einmal pro Build; 796 Collection-Einträge über vier Locales; 136 Seiten. Der Build ist nicht dramatisch schneller — Strapi läuft auf derselben Maschine und war nie der Engpass — aber die Form stimmt endlich, und die Menge an Code, die nur existierte, um die alte Form zu umgehen, ist jetzt null.