---
name: tibi-ssr-caching
description: Implement and debug server-side rendering with goja (Go JS runtime) and dependency-based HTML cache invalidation for tibi-server. Use when working on SSR hooks, cache clearing, or the server-side Svelte rendering pipeline.
---

# tibi-ssr-caching

This skill should teach the **SSR architecture and implementation pattern** used in this repo family, not just describe one demo content setup. The important question is: **how is SSR built here, where is responsibility split, and which parts must be adapted per project?**

## SSR request flow

1. `ssr/get_read.js` receives a page request and calls `lib/ssr-server.js`.
2. `ssr/get_read.js` loads `lib/app.server.js` and calls `app.default.render({ url })`.
3. `frontend/src/ssr.ts` only initializes i18n and delegates rendering to `svelte/server`.
4. `frontend/src/App.svelte` owns the actual data loading for both browser and SSR.
5. During SSR, the app calls its normal page-loading path directly inside a `typeof window === "undefined"` guard.
6. During browser navigation, the same page-loading path is triggered from `$effect`.
7. API calls made during SSR are tracked as dependency strings (`col:id` or `col:*`) and cached in `window.__SSR_CACHE__`.
8. The rendered HTML + dependency list are stored in the `ssr` collection.

## Responsibility split

- `frontend/src/ssr.ts` should stay minimal.
- `frontend/src/ssr.ts` is responsible for SSR bootstrapping only: locale setup, SSR-safe render wrapper, and calling `render(App, { props: { url } })`.
- The app component should own data loading.
- Hooks under `api/hooks/ssr/` should own caching, cache lookup, and cache persistence.
- `api/hooks/lib/ssr.js` should own the shared API helper that works in both browser and SSR.

If these responsibilities get mixed together, SSR usually becomes harder to reason about and harder for an LLM to modify safely.

## Building the SSR bundle

```bash
yarn build:server
```

- Output: `api/hooks/lib/app.server.js`
- The project no longer uses Babel for SSR.
- The goja-compatible transform happens in `esbuild.config.server.js` via `supported`:
    - `async-await: false`
    - `async-generator: false`
    - `dynamic-import: false`
- The SSR build writes directly to `api/hooks/lib/app.server.js`.
- Remove splitting-related frontend options (`outdir`, `splitting`, `entryNames`, `chunkNames`, `outExtension`) from the server build, otherwise esbuild will fail with `outfile`/`outdir` conflicts.

## Core design rule

- Prefer **one shared data-loading path** for browser and SSR.
- The browser should trigger it reactively.
- SSR should call that same path explicitly before rendering completes.
- Avoid maintaining a separate SSR-only content-loading implementation unless there is no viable alternative.

In this repo family, the practical pattern is:

- browser: `$effect(() => loadContent(...))`
- SSR: call the same `loadContent(...)` once inside a server guard

The main trap is assuming `$effect` alone is enough for SSR. It is not.

## Dependency-based cache invalidation

When content changes, `clear_cache.js` only invalidates SSR entries that depend on the changed collection/entry:

```js
// Each SSR cache entry stores dependency strings:
{
        path: "/de/ueber-uns",
        content: "...",
        dependencies: ["content:abc123", "navigation:*", "medialib:*"]
}
```

- `col:id` means a detail dependency.
- `col:*` means a list dependency.
- `clear_cache.js` must handle `DELETE` robustly, because `context.data.id` and route params may be missing. Fallback to the last path segment if needed.
- `utils.clearSSRCache()` must clear:
    - `col:*` on `POST`
    - `col:id` OR `col:*` on `PUT`/`DELETE`
    - everything on manual clear (`POST /ssr?clear=1` with no collection context)

## Limit: 1 ensures precise dependencies

By default, an API query for a collection (like `/api/v1/_/content?filter=...`) sets a list dependency `collection:*`. This means _any_ change to ANY entry in that collection will clear the SSR cache for this page.

If you are querying a single document (like a page or article based on its path or slug), you should ALWAYS append `limit: 1` to your API call (or pass `{ filter: {...}, limit: 1 }` to `getDBEntries`).

When `api/hooks/lib/ssr-server.js` intercepts a request with `limit === 1` and exactly one result is returned, it will register a precise `collection:id` dependency instead of a wildcard `collection:*`. This optimizes the cache drastically, because edits to _other_ pages won't invalidate this page.

### Automatic dependency tracking via `lookup` and `aggregate`

When options like `lookup` (e.g. `lookup: "image:medialib"`) or `aggregate` (e.g. `aggregate: "comments:contentId:count"`) are provided to an API call, `ssr-server.js` automatically parses these values and adds wildcard cache dependencies (`medialib:*` or `comments:*`) to the page. This guarantees that if a referenced image or child comment changes, the parent's SSR HTML is correctly flushed.

## How SSR data loading is supposed to work

- Keep `frontend/src/ssr.ts` thin. It should set up locale state and call `render(App, { props: { url } })`.
- Do not move application-specific prefetch logic into `ssr.ts` unless absolutely necessary.
- The app itself should own the page-loading behavior.
- In projects using this starter architecture, the correct pattern is:
    - browser: `$effect(() => loadContent(...))`
    - SSR: call the same `loadContent(...)` once inside `typeof window === "undefined"`
- This keeps SSR and client navigation on one shared code path.
- `loadContent(...)` must load **all data required for a fully rendered page**. In this repo that includes both navigation and page content. SSR is incomplete if only the main content entry is loaded.
- Because goja runs the transformed async path synchronously enough for this setup, the direct SSR call works. The problem was the reactive `$effect`, not the shared async loader itself.

## What is project-specific vs. architecture-specific

Architecture-specific rules:

- SSR entry goes through `api/hooks/ssr/get_read.js`
- HTML caching lives in the `ssr` collection
- SSR API calls are tracked through `context.ssrRequest`
- Client hydration reuses `window.__SSR_CACHE__`
- The app owns its own data-loading logic

Project-specific rules that an LLM must inspect before changing SSR:

- which collections contribute to rendered pages
- which routes should SSR vs. skip SSR
- whether URLs are language-prefixed
- whether DB paths are stored with or without language prefix
- which lookups are required to make a page fully render
- which collections need publication-aware invalidation
- whether there are canonical/alias paths

Do not hardcode demo assumptions into the skill. Instead, use the architecture rules above and inspect the current project's route model, collections, and page-loading code.

## SSR route validation

Route validation in `config.js` controls which paths get SSR treatment. Return:

- `1` to render the requested path as-is
- a string to rewrite to the canonical cache path
- `-1` for not found

For projects following this setup, route validation must understand the public URL shape used by the frontend router:

- `/` and `/{lang}` are valid SSR roots.
- Public content URLs are language-prefixed (`/de/...`, `/en/...`).
- Content entries in the DB are stored **without** the language prefix in `content.path`.
- `ssrValidatePath()` therefore needs to:
    - extract the language prefix from the URL
    - strip it before querying `content.path`
    - include `{ lang }` in the content query
    - support `alternativePaths.path`
    - return a canonical language-prefixed URL when the request matched via an alternative path

If this mapping is wrong, SSR may appear to work for root pages while returning 404 or empty content for real CMS pages.

## Publication-aware SSR caching

- `config.js` exports `publishedFilter` and `ssrPublishCheckCollections`.
- `ssrPublishCheckCollections` should include every collection whose publication window can make cached HTML stale.
- In this starter, `content` is currently included.
- `ssr-server.js` uses `publication.from` / `publication.to` to compute `context.ssrCacheValidUntil`.
- `get_read.js` must reject expired cache entries and delete them before rendering anew.

## Hydration cache behavior

- `api/hooks/lib/ssr.js` uses the same API helper for browser and SSR.
- On the server, `apiRequest(...)` delegates to `context.ssrRequest(...)`.
- On the client, `window.__SSR_CACHE__` is checked first for GET requests.
- This means SSR is not just HTML prerendering; it also primes client-side data access.
- If HTML renders but `window.__SSR_CACHE__` is missing, the SSR pipeline is incomplete.

## SSR 404 signaling

When a page is not found during SSR, the framework returns the 404 page but with HTTP status **200** unless a 404 signal is set. The SSR hook (`get_read.js`) checks `context.is404` after rendering:

```js
// get_read.js, after app.default.render()
if (context.is404) {
    status = 404
}
```

The signal is set from `NotFound.svelte` — when this component is rendered during SSR, it sets the flag directly. This keeps the 404 logic in the component that owns it:

```ts
// NotFound.svelte — top-level script, runs during render:
if (typeof window === "undefined") {
    // @ts-ignore - context is the goja global in SSR runtime
    context.is404 = true
}
```

**Why this works:**

- The `tibi-types` package declares `var context: HookContext` as a global (available because goja provides it during SSR).
- During SSR, `loadContent()` runs synchronously (goja transforms `async`/`await`).
- By the time `render(App)` returns in `ssr.ts`, `context.is404` is already `true`.
- `get_read.js` reads it, returns HTTP 404, and the rendered 404 page HTML is sent with the correct status.
- Caching is automatically skipped for 404 responses.

**Verification:** Test with a non-existent URL:

```bash
curl -w "\nHTTP Status: %{http_code}\n" "http://tibiserver:8080/api/v1/_/<namespace>/ssr?url=/de/nicht-existierend"
# Expected: HTTP 404, body contains the 404 page HTML
```

## What an LLM should inspect first when changing SSR

1. `api/hooks/ssr/get_read.js` to understand cache lookup, route validation, and template injection.
2. `api/hooks/lib/ssr-server.js` to understand dependency tracking and SSR-side API behavior.
3. `frontend/src/ssr.ts` to confirm how the SSR render wrapper is bootstrapped.
4. The top-level app/page-loading surface (for example `frontend/src/App.svelte`) to see where data is actually loaded.
5. `api/hooks/config.js` to understand route validation, canonicalization, and publication-aware collections.
6. `api/hooks/clear_cache.js` plus `api/hooks/lib/utils.js` to understand invalidation behavior.

This order helps an LLM separate infrastructure problems from app-loading problems.

## How to verify SSR correctly

- Do not rely only on the BrowserSync/frontend proxy when debugging SSR.
- Test the SSR API endpoint directly, for example:

```bash
curl "http://tibiserver:8080/api/v1/_/<namespace>/ssr?url=/de/ueber-uns"
```

- Verify all of the following:
    - HTTP status is correct
    - expected page content is present in the HTML
    - all page-critical content is present in the HTML
    - navigation labels are present in the HTML when navigation is part of the app shell
    - `window.__SSR_CACHE__` exists
    - no `error:` comment was injected into the template
    - second request returns `X-SSR-Cache: true`
    - `POST /ssr?clear=1` removes cache entries and the next request is a miss again

## Common pitfalls

- **Do not document Babel anymore**: the current SSR build is esbuild-only.
- **goja does not parse every modern syntax feature**: dynamic import must be downlevelled in the server build.
- **Do not leave frontend build options on the server build**: `splitting`/`outdir` inherited from the frontend config will break `build:server`.
- **No browser globals**: `window`, `document`, `localStorage` etc. don't exist in goja. Guard with `typeof window !== "undefined"`.
- **`$effect` does not solve SSR loading**: server-side content must be loaded outside browser-only reactive effects.
- **SSR can look healthy while content is missing**: a 200 response plus app shell is not enough; always verify actual DB content in the HTML.
- **Navigation is part of SSR**: if header/footer are missing, the SSR setup is still incomplete even when the page body renders.
- **SSR cache can go stale**: Always ensure `clear_cache.js` covers every collection that affects rendered output.
- **Do not overfit the skill to demo content**: the skill should explain the architecture and where to inspect project-specific route/content rules, not freeze one content model as universal.

## SSR data loading pattern

In Svelte 5, SSR data loading works via **top-level `loadData()` calls** (NOT inside `$effect`):

```typescript
// ✅ Richtig: Top-Level-Aufruf für SSR + Browser
loadData()

async function loadData() {
    const data = await getCachedEntries(...)
    state = data
}

// ❌ Falsch: $effect wird für SSR nicht rechtzeitig abgearbeitet
$effect(() => { loadData() })
```

**Warum das funktioniert:**

- `loadData()` läuft während der Component-Initialisierung (vor Template-Auswertung)
- `getCachedEntries` → `apiRequest` → SSR-Pfad → `context.ssrRequest()` → blockierender HTTP-Fetch in goja
- goja's `await` auf einem bereits aufgelösten Promise läuft synchron weiter (kein Microtask-Hickhack)
- State-Änderungen sind vor der Template-Auswertung sichtbar

**Browser-Reaktivität:** Wenn Props sich ändern (z.B. Navigation zu anderer Kategorie), wird die Component via `{#if}`/`{#key}` neu erstellt → `loadData()` läuft erneut.

## SSR-Cache in der Entwicklung

Der SSR-Cache ist das häufigste Debugging-Hindernis. Der Proxy in `esbuild.config.js` MUSS `&noCache=1` an den SSR-Request anhängen:

```javascript
// esbuild.config.js – SSR-Proxy
pathRewrite: function (path, req) {
    return "/ssr?url=" + encodeURIComponent(path) + "&noCache=1"
}
```

Ohne `noCache` wird die erste SSR-Antwort gecached und bei Code-Änderungen nicht invalidiert. Der Entwickler sieht immer den alten Stand. **Immer zuerst den Cache-Bypass prüfen, bevor SSR-Fehler gesucht werden.**

**Erkennungsmerkmale für veralteten SSR-Cache:**

- `X-SSR-Cache: true` im Response-Header
- `<!--COMMENT--><!--SSR.ERROR-->` im HTML
- `__SSR_CACHE__` enthält nicht die erwarteten Daten
- Neustart von tibi-server nötig nach `app.server.js`-Änderungen (`docker restart <tibiserver>`)

## Build-Arbeitsschritte bei SSR-Änderungen

Nach jeder Änderung an Svelte-Komponenten oder `api.ts` ist folgendes nötig:

```bash
# 1. Frontend-Bundle bauen
yarn build

# 2. SSR-Bundle bauen (app.server.js)
yarn build:server

# 3. tibi-server neustarten (lädt neues app.server.js)
docker restart <tibiserver>

# 4. Frontend neustarten (für Entwicklungs-Proxy)
make docker-restart-frontend
```

**Wichtig:** `yarn build:server` allein reicht nicht – der tibi-server cached das Modul im Speicher und lädt es nur beim Start neu.
