✨ feat: enhance project setup and architecture documentation
- Updated `tibi-project-setup` skill to clarify project initialization goals and steps. - Improved `tibi-ssr-caching` skill to detail SSR architecture, responsibilities, and caching mechanisms. - Introduced `website-solution-architecture` skill for translating website requirements into coherent solutions. - Refined `AGENTS.md` to provide a structured roadmap for project development phases. - Added `ADMIN_ASSET_VERSION` to `api/config.yml.env` for asset versioning. - Updated SSR request flow and cache invalidation logic in `api/hooks/ssr/AGENTS.md`. - Removed obsolete `esbuild.config.admin.js` and integrated asset versioning into the main `esbuild.config.js`. - Adjusted `api/collections/content.yml` to utilize asset versioning for admin scripts.
This commit is contained in:
@@ -5,13 +5,28 @@ description: Implement and debug server-side rendering with goja (Go JS runtime)
|
||||
|
||||
# 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-server.js` loads `lib/app.server.js` (the Svelte SSR bundle) and renders the page.
|
||||
3. During rendering, API calls are tracked as **dependencies** (collection + entry ID).
|
||||
4. The rendered HTML + dependencies are stored in the `ssr` collection.
|
||||
5. On the client, `lib/ssr.js` hydrates using `window.__SSR_CACHE__` injected by the server.
|
||||
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
|
||||
|
||||
@@ -20,36 +35,159 @@ yarn build:server
|
||||
```
|
||||
|
||||
- Output: `api/hooks/lib/app.server.js`
|
||||
- Uses `babel.config.server.json` to transform async/await to generators (goja doesn't support async).
|
||||
- Add `--banner:js='// @ts-nocheck'` to suppress type errors in the generated bundle.
|
||||
- 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 its dependencies:
|
||||
// Each SSR cache entry stores dependency strings:
|
||||
{
|
||||
url: "/some-page",
|
||||
html: "...",
|
||||
dependencies: [
|
||||
{ collection: "content", id: "abc123" },
|
||||
{ collection: "medialib", id: "def456" }
|
||||
]
|
||||
path: "/de/ueber-uns",
|
||||
content: "...",
|
||||
dependencies: ["content:abc123", "navigation:*", "medialib:*"]
|
||||
}
|
||||
```
|
||||
|
||||
The hook queries the `ssr` collection for entries whose `dependencies` array matches the changed collection (and optionally entry ID), then deletes only those cached pages.
|
||||
- `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)
|
||||
|
||||
## 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:
|
||||
|
||||
- A positive number to enable SSR for that route
|
||||
- `-1` to disable SSR (current default in the starter template)
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
|
||||
- **goja has no async/await**: The babel server config transforms these, but avoid top-level await.
|
||||
- **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"`.
|
||||
- **SSR cache can go stale**: Always ensure `clear_cache.js` covers any new collection that affects rendered output.
|
||||
- **`$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.
|
||||
|
||||
Reference in New Issue
Block a user