Zum Inhalt springen

Website mit SSR

Deine Website ist eine Svelte-5-App mit Server-Side-Rendering. Der tibi-server rendert sie pro URL vor und legt das HTML in der Sammlung ssr ab. Container: Seiten-Routen, Daten-Lookups, Blöcke, SSR-Bundle, Cache.

  • Ordnerfrontend/src/
    • App.svelte
    • ssr.ts
    • Ordnerblocks/
      • …
  • frontend/spa.html
  • api/templates/spa.html
  • api/collections/ssr.yml
  • Ordnerapi/hooks/
    • lib/app.server.js
    • lib/utils.js
    • ssr/get_read.js
    • ssr/post_bind.js
    • clear_cache.js
URL Was passiert
/ Leitet auf die Browser-Sprache um (/de/ oder /en/)
/:lang/* z. B. /de/, /de/ueber-uns — Sprache plus Seitenpfad
Unbekannter Pfad NotFound.svelte (404)
  • content.path steht ohne Sprachpräfix in der Datenbank (/, nicht /de/).
  • Pfad-Regel: ^/[a-z0-9-/]*$ — klein, keine Umlaute.
  • translationKey + lang und path + lang sind je eindeutig. Setze immer translationKey, sonst kollidiert die zweite Seite auf null/de.
  • Sprache steckt im Pfad (extractLanguageFromPath), Titel und Meta kommen aus contentEntry.meta.

App.svelte lädt pro Seite drei Dinge über getCachedEntries:

frontend/src/App.svelte — die drei Abfragen
const CONTENT_MEDIA_LOOKUP = ["blocks.heroImage.image:medialib", "blocks.image:medialib"].join(",")
const NAVIGATION_CONTENT_LOOKUP = "elements.page:content"
// Inhalt: genau ein Treffer (limit 1), nur aktiv, mit Medien-Lookup und Kommentar-Zähler
getCachedEntries("content", {
filter: { lang, path: routePath, active: true },
limit: 1, sort: "sort",
aggregate: { collection: "comment", foreignField: "contentId", op: "count",
filter: { active: true }, as: "commentCount" },
lookup: CONTENT_MEDIA_LOOKUP,
})
// Navigation: je ein Eintrag header/footer mit aufgelösten Seitenpfaden
getCachedEntries("navigation", {
filter: { type: "header", language: lang }, lookup: NAVIGATION_CONTENT_LOOKUP, ...
})
Lookup Wirkung
blocks.heroImage.image:medialib, blocks.image:medialib Bild-IDs werden zu _lookup-Objekten mit file.src
elements.page:content Navigations-Elemente bekommen den Zielpfad (_lookup.page.path)
Kommentar-Aggregat commentCount ohne zweite Abfrage

sort: "sort" sortiert nach dem Feld sort (manuelle Reihenfolge, aufsteigend) — der Parametername und der Feldname heißen hier zufällig gleich.

BlockRenderer.svelte mappt content.blocks auf Komponenten:

type Komponente Hinweis
hero HeroBlock.svelte Ohne Bild: Verlauf (Gradient-Fallback)
features FeaturesBlock.svelte Icons über FeatureIcon.svelte
richtext RichtextBlock.svelte Bilder nur bei aufgelöstem _lookup.image.file.src
accordion AccordionBlock.svelte Aufklapp-Sektionen
contact-form ContactFormBlock.svelte Formular aus widgets/ (Form, Input, Select, Button)
  • hide: true blendet einen Block aus.
  • Unbekannte Typen werden still übersprungen — nur auf localhost erscheint ein gelber Hinweis.
  • Bilder laufen ausschließlich über MedialibImage.svelte (file.src oder Data-URI, kein ?filter auf Data-URIs).
  1. Feld in api/collections/content.yml unter blocks ergänzen: type plus Felder mit dependsOn: $parent.type — so zeigt der Editor nur passende Felder.
  2. Komponente in frontend/src/blocks/ anlegen, z. B. QuoteBlock.svelte:
    • Props per Runen: let { block } = $props() — block nie destrukturieren.
    • Abgeleitete Werte mit $derived, Zustand mit $state.
    • SSR-sicher schreiben: jeder window-Zugriff braucht einen Guard (typeof window !== "undefined").
    • Texte über svelte-i18n ($_), Styling Tailwind v4 (bg-linear-to-*, aspect-video, wichtiges ! hinten: bg-brand-600!).
  3. Fall in BlockRenderer.svelte ergänzen:
    BlockRenderer.svelte — neuen Fall einhängen
    {:else if block.type === "quote"}
    <QuoteBlock {block} />
  4. Seite mit dem neuen Block anlegen und anonym prüfen (nächster Abschnitt).

Der Server ruft app.default.render({ url }) auf. Den Wrapper liefert frontend/src/ssr.ts (Svelte-5-render aus svelte/server, i18n-Nachrichten synchron geladen, Sprache aus der URL).

SSR-Bundle: bauen, nie von Hand anfassen
frontend/src/ssr.ts ── esbuild (esbuild.config.server.js) ──▶ api/hooks/lib/app.server.js
Svelte 5, generate: "server" gebaut, nie von Hand anfassen
Befehl Wirkung
yarn build Frontend-Bundle (frontend/dist)
yarn build:server SSR-Bundle nach api/hooks/lib/app.server.js (Ziel esnext, goja-kompatibel)

Beide Builds schreiben buildInfo — der Server-Build überschreibt sie nie, sonst löst checkBuildVersion einen Schein-Reload aus. Nach jedem Frontend-Build: yarn build:server erneut laufen lassen und deployen (siehe Veröffentlichen).

Quelldatei der Vorlage ist frontend/spa.html. ci-modify-config.sh ersetzt __TIMESTAMP__ durch einen Build-Stempel und kopiert die Datei nach api/templates/spa.html. get_read.js liest sie pro Anfrage und füllt die Marker.

Marker Inhalt
<!--HEAD--> Head aus dem SSR-Render
<!--HTML--> Gerendertes App-HTML
<!--PRELOAD--> Preload-Hinweise (scripts/preload-meta.sh)
__TIMESTAMP__ Build-Stempel für /dist-Assets (Cache-Buster, schon beim Kopieren ersetzt)
<!--SSR.ERROR--> Render-Fehler als Kommentar (leer im Normalfall)
<!--SSR.COMMENT--> Diagnose (url, deps) als Kommentar
<html lang> Sprache aus dem URL-Präfix (de/en, sonst de)

Die Sammlung ssr (api/collections/ssr.yml) hält einen Eintrag je URL:

Feld Rolle
path URL ohne Query, eindeutig (Unique-Index — parallele Writes gewinnt genau einer)
content Fertiges HTML aus der Vorlage
validUntil Ablaufdatum — der Hook setzt es derzeit auf null, Einträge leben bis zur Invalidierung
dependencies col:<id> (Detail) oder col:* (Liste), indexiert
_testdata Test-Marker, im Editor verborgen

Schreiben und Löschen steuert clear_cache.js als return-Hook (post, put, delete u. a. in content, navigation, medialib, comment, tag) über utils.clearSSRCache(); POST /api/ssr?clear=1 wischt alles (post_bind.js).

api/hooks/ssr/get_read.js rendert jede anonyme Anfrage — außer Tracking (x-ssr-skip) und Admins:

  1. URL normieren (Query ab, Schrägstrich ab, /index → /), per ssrValidatePath prüfen (404 oder kein Rendering).
  2. Cache in der Sammlung ssr suchen (path ist eindeutig). Treffer mit X-SSR-Cache: true ausliefern; abgelaufene (validUntil) löschen.
  3. Neu rendern, window.__SSR_CACHE__ mit den gelesenen Einträgen einbetten, Abhängigkeiten als col:id (Detail) oder col:* (Liste) speichern. Gibt es col:*, fallen die col:id derselben Sammlung weg.
  4. Parallel geschriebene Duplikate (duplicate key auf path) werden geschluckt — der Eintrag existiert dann schon.

Invalidierung übernimmt api/hooks/lib/utils.js (clearSSRCache), verdrahtet über clear_cache.js als return-Hook (u. a. content, navigation, medialib, comment, tag):

Methode Was gelöscht wird
POST (neuer Eintrag) Nur Seiten mit col:* (Listen)
PUT / DELETE Seiten mit col:<id> oder col:*
POST /api/ssr?clear=1 Alles (manueller Komplett-Wisch)

Jede andere POST-Anfrage an ssr antwortet 500 (ssr is only a dummy collection). Antwort-Köpfe X-SSR-Cleared und X-SSR-Cleared-Collection sagen, was flog.

SSR-HTML ohne Login lesen
curl -s http://localhost:8080/de/ | grep -o 'window.__SSR_CACHE__ = .*' | head -c 600
# Erwartung: navigation count > 0, heroImage/image mit _lookup
curl -sI http://localhost:8080/de/ | grep -i x-ssr-cache
# Zweiter Aufruf: X-SSR-Cache: true
Fehlerbild Ursache Lösung
Öffentlich leer, mit Token voll active:true fehlt (auch auf Navigation/Medien) Flag setzen, anonym erneut prüfen
X-SSR-Cache: true trotz neuer Daten Abhängigkeit fehlt (z. B. Liste ohne col:*) ?clear=1 oder ?noCache=1, Hook-Verdrahtung prüfen
500 ssr is only a dummy collection POST an ssr ohne ?clear=1 Query-Parameter ergänzen
PAT reicht, anonym fehlt Lookup lookup permission denied — Ziel-Sammlung ohne Leserecht Rechte und Tokens

Verifiziert am e3f92eb 2026-10-17 (tibi-svelte-starter)