Files
tibi-docs/src/content/docs/projekt/ssr-website.mdx
T
apairon 99f61f6c0b Fix aus Welle-I-Review: Code-Beispiele in ssr-website wieder als HTML-Kommentare
Die Umwandlung <!-- --> -> {/* */} hatte in projekt/ssr-website auch fuenf Code-Spans erwischt
(<!--HEAD-->, <!--HTML-->, <!--PRELOAD-->, <!--SSR.ERROR-->, <!--SSR.COMMENT-->).
Zurueckgedreht, im gebauten HTML geprueft. Review-Ergebnis in plans/doku-audit.md dokumentiert.
2026-10-17 13:57:25 +00:00

213 lines
9.4 KiB
Plaintext

---
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.
<FileTree>
- 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
</FileTree>
## 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.
<Aside type="caution">
Öffentlich sichtbar ist nur `active: true` — das gilt auch für `navigation` und `medialib` (kanonische Erklärung unter [Rechte und Tokens](/projekt/rechte-tokens/)).
</Aside>
## 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
<Steps>
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"}
<QuoteBlock {block} />
```
4. Seite mit dem neuen Block anlegen und anonym prüfen (nächster Abschnitt).
</Steps>
## 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-->` | 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`).
## Cache und Invalidierung
`api/hooks/ssr/get_read.js` rendert jede anonyme Anfrage — außer Tracking (`x-ssr-skip`) und Admins:
<Steps>
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.
</Steps>
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.
## Abnahme
<Tabs>
<TabItem label="Anonym prüfen">
```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
```
</TabItem>
<TabItem label="Nach Änderung">
```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
```
</TabItem>
</Tabs>
| 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)