--- title: 'Website mit SSR' description: 'Routen, Daten-Lookups, Blöcke, SSR-Bundle, Cache und Invalidierung im Starter.' --- import { Aside, Tabs, TabItem, Steps, FileTree } from '@astrojs/starlight/components'; 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. - frontend/src/ - App.svelte - ssr.ts - blocks/ - frontend/spa.html - api/templates/spa.html - api/collections/ssr.yml - api/hooks/ - lib/app.server.js - lib/utils.js - ssr/get_read.js - ssr/post_bind.js - clear_cache.js ## Routen | 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`. ## Daten laden und Lookups `App.svelte` lädt pro Seite drei Dinge über `getCachedEntries`: ```ts title="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. ## Blöcke `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). ## Eigener Block 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: ```svelte title="BlockRenderer.svelte — neuen Fall einhängen" {:else if block.type === "quote"} ``` 4. Seite mit dem neuen Block anlegen und anonym prüfen (nächster Abschnitt). ## SSR-Bundle 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). ```text title="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](/projekt/deploy/)). ## Vorlage und Cache-Sammlung 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 aus dem SSR-Render | | `` | Gerendertes App-HTML | | `` | Preload-Hinweise (`scripts/preload-meta.sh`) | | `__TIMESTAMP__` | Build-Stempel für `/dist`-Assets (Cache-Buster, schon beim Kopieren ersetzt) | | `` | Render-Fehler als Kommentar (leer im Normalfall) | | `` | Diagnose (`url`, `deps`) als Kommentar | | `` | 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:` (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`). ## Cache und Invalidierung `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:` **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. ## Abnahme ```bash title="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 ``` ```bash title="Cache-Verhalten nach PUT/DELETE" # PUT/DELETE auf einen Eintrag → betroffene Seiten neu gerendert (kein X-SSR-Cache: true beim nächsten Aufruf) # POST (neu) → nur Listen-Seiten neu # Zweifel? POST /api/ssr?clear=1 wischt alles curl -s "http://localhost:8080/de/?noCache=1" | head -c 300 ``` | 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](/projekt/rechte-tokens/) | Verifiziert am e3f92eb 2026-10-17 (tibi-svelte-starter)