diff --git a/README.md b/README.md index ffa8b93..2e1255c 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,13 @@ All commands are run from the root of the project, from a terminal: ## ⚙️ Configuration -The app renders **server-side** (`prerender = false`) and proxies requests to a device API. It reads one env var: +The app is a mix of **prerendered** content pages (about, how it works, editorial stance, imprint — one static file per locale, generated under `src/pages/[...locale]/`) and **server-rendered** pages that depend on the device API or on query parameters (home, browse, device detail, surveys, 404, `robots.txt`). It reads these env vars: -| Variable | Required | Default | Description | -| :-------------- | :------- | :---------------------- | :-------------------------------------------------------------------------------------------------- | -| `API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API. The app fetches `/api/unstable/derived/devices` from here, server-side. | -| `NOINDEX` | No | `true` | Adds `noindex` for preview deploys. Set to `false` for a real production deploy. | +| Variable | Required | Default | Description | +| :------------------ | :------- | :---------------------- | :-------------------------------------------------------------------------------------------------------------------------- | +| `API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API. The app fetches `/api/unstable/derived/devices` from here, server-side. | +| `CSR_API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API for client-side fetches (search box, filters). Served to the browser by `/config.js` at runtime. | +| `NOINDEX` | No | `true` | Adds `noindex` for preview deploys. Set to `false` for a real production deploy. | Create a `.env` for local runs (auto-loaded by Astro and `netlify dev`): @@ -88,7 +89,7 @@ docker run -p 4321:4321 \ device-database-frontend ``` -**Runtime config.** All app config is runtime-only: the server reads `API_AUTHORITY` and `NOINDEX` from the environment (`-e`), so a single image serves every environment — nothing is baked in at build time. `NOINDEX` defaults to `true` (no indexing) when unset; every page is server-rendered, so a flip takes effect on container restart (plus CDN cache TTL). +**Runtime config.** All app config is runtime-only: the server reads `API_AUTHORITY`, `CSR_API_AUTHORITY` and `NOINDEX` from the environment (`-e`), so a single image serves every environment — nothing is baked in at build time. `NOINDEX` defaults to `true` (no indexing) when unset; a flip takes effect on container restart (plus CDN cache TTL). Two consequences of prerendering: the client-side API authority is served by the `/config.js` endpoint rather than inlined into the HTML, and prerendered pages carry no `robots` meta tag or `X-Robots-Tag` header (they would freeze the build-time value) — `robots.txt`, which is server-rendered, remains the crawl gate for preview deploys. The server listens on `HOST=0.0.0.0` and `PORT=4321` (both overridable via env vars). The runtime image ships only the bundled server output — the node build bundles all dependencies into `dist/` (`vite.ssr.noExternal`), so there is no `node_modules` in the final image. One consequence: Astro's sharp-backed `/_image` endpoint is unavailable in the container (the project doesn't use `astro:assets`, so nothing depends on it — revisit if that changes). diff --git a/astro.config.mjs b/astro.config.mjs index 74bb0b7..3d55f6a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -44,11 +44,15 @@ const prefixedLocales = [ // https://astro.build/config export default defineConfig({ + // `localizeHref()` emits links without a trailing slash; `/about/` redirects to `/about`. + trailingSlash: "never", build: { // External stylesheets persist correctly across ClientRouter navigations. inlineStylesheets: "never", + // Write prerendered pages as `about.html`, with `about/index.html` Netlify + // would redirect every `/about` link to `/about/`. + format: "file", }, - output: "server", env: { schema: { // access: "secret" keeps the value out of the build output; it is read @@ -66,7 +70,8 @@ export default defineConfig({ }), // Preview edition is no-indexed by default. Set NOINDEX=false in the runtime // environment for a real production deploy. Like API_AUTHORITY it is not baked - // into the image: every page that consumes it is server-rendered. + // into the image: server-rendered responses consume it at request time, + // prerendered pages leave indexing to robots.txt (see Layout.astro). NOINDEX: envField.boolean({ context: "server", access: "secret", diff --git a/src/io/index.ts b/src/io/index.ts index 3c6f9a9..5818ef8 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -3,18 +3,37 @@ import { z } from "astro/zod"; import { exactlyOne } from "../types/exactly-one"; const REQUEST_TIMEOUT_MS = 8000; -let API_AUTHORITY: string; +let SERVER_API_AUTHORITY: string | undefined; if (import.meta.env.SSR) { - ({ API_AUTHORITY } = await import("astro:env/server")); -} else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- defined dynamically in `Layout.astro`` - API_AUTHORITY = (window as any).__API_AUTHORITY__; + ({ API_AUTHORITY: SERVER_API_AUTHORITY } = await import("astro:env/server")); } -export const ioBaseUrl = (): string => - /^https?:\/\//.test(API_AUTHORITY) - ? API_AUTHORITY - : `https://${API_AUTHORITY}`; +declare global { + interface Window { + /** set by `/config.js`, see `src/pages/config.js.ts` */ + __API_AUTHORITY__?: string; + } +} + +// resolved per call rather than at module evaluation, so the island bundle +// never depends on `/config.js` having run before it was imported +const apiAuthority = (): string => { + const authority = import.meta.env.SSR + ? SERVER_API_AUTHORITY + : window.__API_AUTHORITY__; + + if (typeof authority === "undefined") { + throw new Error("API authority is not configured"); + } + + return authority; +}; + +export const ioBaseUrl = (): string => { + const authority = apiAuthority(); + + return /^https?:\/\//.test(authority) ? authority : `https://${authority}`; +}; export class IoError extends Error { constructor( diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index d0020ca..fabfca3 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,5 +1,5 @@ --- -import { CSR_API_AUTHORITY, NOINDEX } from "astro:env/server"; +import { NOINDEX } from "astro:env/server"; import { ClientRouter } from "astro:transitions"; import Footer from "../components/Footer.astro"; @@ -25,11 +25,21 @@ const { title, page = "page", back } = Astro.props; - {NOINDEX && } + { + /* NOINDEX is a runtime value. A prerendered page would freeze whatever the + build saw, so it carries no robots meta; robots.txt (server-rendered) + remains the crawl gate for preview deploys. */ + NOINDEX && !Astro.isPrerendered && ( + + ) + } - + { + /* Runtime API authority for client-side fetches, served by + src/pages/config.js.ts so prerendered pages pick it up too. `defer` + keeps it ahead of the island modules without blocking parsing. */ + } +