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.
213 lines
9.4 KiB
Plaintext
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)
|