Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down Expand Up @@ -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).

Expand Down
9 changes: 7 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
37 changes: 28 additions & 9 deletions src/io/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 15 additions & 5 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,11 +25,21 @@ const { title, page = "page", back } = Astro.props;
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<meta name="theme-color" content="#2a2a27" />
{NOINDEX && <meta name="robots" content="noindex, nofollow" />}
{
/* 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 && (
<meta name="robots" content="noindex, nofollow" />
)
}

<script define:vars={{ CSR_API_AUTHORITY }} is:inline>
window.__API_AUTHORITY__ = CSR_API_AUTHORITY;
</script>
{
/* 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. */
}
<script src="/config.js" defer is:inline></script>

<script is:inline>
(function () {
Expand Down
24 changes: 19 additions & 5 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,26 @@ import { defineMiddleware, sequence } from "astro:middleware";
import "./io/http-cache.ts";

import { paraglideMiddleware } from "./paraglide/server.js";
import { localeFromParam } from "./utilities/locale";

const paraglide = defineMiddleware((context, next) =>
paraglideMiddleware(context.request, ({ request }) =>
request.url !== context.request.url ? next(request) : next(),
),
);
// Astro routes `src/pages/[...locale]/`, Paraglide resolves the locale for `getLocale()`
const paraglide = defineMiddleware((context, next) => {
const locale = localeFromParam(context.params.locale);

// `[...locale]` also matches non-locale segments (`/en/about`, `/foo/about`)
if (typeof locale === "undefined") {
return paraglideMiddleware(context.request, () => next("/404"));
}

// Paraglide reads the locale from the request URL, also for the request Astro
Comment thread
PhilipTrauner marked this conversation as resolved.
// synthesises per prerendered page. That one warns when its headers are read
// (Paraglide checks `Sec-Fetch-Dest`), so pass a bare request built from the URL.
const request = context.isPrerendered
? new Request(context.url)
: context.request;

return paraglideMiddleware(request, () => next());
});

const noindex = defineMiddleware(async (_, next) => {
const response = await next();
Expand Down
6 changes: 3 additions & 3 deletions src/pages/404.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import Layout from "../layouts/Layout.astro";

import { localizeHref } from "../paraglide/runtime.js";

// Like every other page: the locale comes from the request, so this cannot be
// prerendered. Prerendering it would resolve the locale at build time and send
// a base-locale page to everyone, whatever prefix they asked for.
// Astro only serves a 404 route from the root of src/pages, so this page has
// no locale segment. The locale comes from the request URL instead, which is
// why it cannot be prerendered: a static file would be base-locale for everyone.
export const prerender = false;
---

Expand Down
15 changes: 7 additions & 8 deletions src/pages/about.astro → src/pages/[...locale]/about.astro
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
---
import byOhfDark from "../assets/by-ohf-dark.svg";
import byOhfLight from "../assets/by-ohf-light.svg";
import byOhfDark from "../../assets/by-ohf-dark.svg";
import byOhfLight from "../../assets/by-ohf-light.svg";

import Layout from "../layouts/Layout.astro";
import Layout from "../../layouts/Layout.astro";

import { m } from "../paraglide/messages.js";
import { localizeHref } from "../paraglide/runtime.js";
import { m } from "../../paraglide/messages.js";
import { localizeHref } from "../../paraglide/runtime.js";
import { localeStaticPaths } from "../../utilities/locale";

export const prerender = false;

Astro.cache.set({ maxAge: 86400 });
export const getStaticPaths = localeStaticPaths;
---

<Layout page="page" title={m.about_title()}>
Expand Down
26 changes: 13 additions & 13 deletions src/pages/browse.astro → src/pages/[...locale]/browse.astro
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
import DeviceCard from "../components/DeviceCard.astro";
import Layout from "../layouts/Layout.astro";
import DeviceCard from "../../components/DeviceCard.astro";
import Layout from "../../layouts/Layout.astro";

import { getDevices } from "../io/device";
import { getDimensions } from "../io/dimension";
import { m } from "../paraglide/messages.js";
import { getLocale, localizeHref } from "../paraglide/runtime.js";
import { getDevices } from "../../io/device";
import { getDimensions } from "../../io/dimension";
import { m } from "../../paraglide/messages.js";
import { getLocale, localizeHref } from "../../paraglide/runtime.js";
import {
browseFiltersCount,
browseFiltersFromSearchParams,
Expand All @@ -18,16 +18,16 @@ import {
withoutCategory,
withoutManufacturer,
withoutTerm,
} from "../types/browse";
} from "../../types/browse";
import {
DeviceCategoryTopLevelId,
topLevelCategoryResolver,
} from "../types/category";
import { devicePolyMap } from "../types/device";
import { paginationItems } from "../types/pagination";
import { cachePolicy } from "../utilities/cache";
import * as presentation from "../utilities/presentation";
import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset";
} from "../../types/category";
import { devicePolyMap } from "../../types/device";
import { paginationItems } from "../../types/pagination";
import { cachePolicy } from "../../utilities/cache";
import * as presentation from "../../utilities/presentation";
import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset";

export const prerender = false;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
---
import haLogo from "../../assets/ha-logo.svg?raw";
import haLogo from "../../../assets/ha-logo.svg?raw";

import Layout from "../../layouts/Layout.astro";
import Layout from "../../../layouts/Layout.astro";

import { getDevice, getDeviceCount } from "../../io/device";
import { getDimensions } from "../../io/dimension";
import { m } from "../../paraglide/messages.js";
import { getLocale, localizeHref } from "../../paraglide/runtime.js";
import { topLevelCategoryResolver } from "../../types/category";
import { deviceMonoMap, devicePolyMap } from "../../types/device";
import { Unknown } from "../../types/unknown";
import { cachePolicy } from "../../utilities/cache";
import * as presentation from "../../utilities/presentation";
import { getDevice, getDeviceCount } from "../../../io/device";
import { getDimensions } from "../../../io/dimension";
import { m } from "../../../paraglide/messages.js";
import { getLocale, localizeHref } from "../../../paraglide/runtime.js";
import { topLevelCategoryResolver } from "../../../types/category";
import { deviceMonoMap, devicePolyMap } from "../../../types/device";
import { Unknown } from "../../../types/unknown";
import { cachePolicy } from "../../../utilities/cache";
import * as presentation from "../../../utilities/presentation";
import {
PresentationRenderPresetRoleGlyph,
PresentationRenderPresetRoleIcon,
} from "../../utilities/presentation/preset";
import type { IoDevicePoly } from "../../io/device";
import type { DeviceCategoryTopLevelId } from "../../types/category";
} from "../../../utilities/presentation/preset";
import type { IoDevicePoly } from "../../../io/device";
import type { DeviceCategoryTopLevelId } from "../../../types/category";

export const prerender = false;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
---
import Layout from "../layouts/Layout.astro";
import Layout from "../../layouts/Layout.astro";

import { m } from "../paraglide/messages.js";
import { m } from "../../paraglide/messages.js";
import { localeStaticPaths } from "../../utilities/locale";

export const prerender = false;

Astro.cache.set({ maxAge: 86400 });
export const getStaticPaths = localeStaticPaths;
---

<Layout page="page" title={m.editorial_title()}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
---
import Layout from "../layouts/Layout.astro";
import Layout from "../../layouts/Layout.astro";

import { m } from "../paraglide/messages.js";
import { generic, render } from "../utilities/presentation";
import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset";
import { m } from "../../paraglide/messages.js";
import { localeStaticPaths } from "../../utilities/locale";
import { generic, render } from "../../utilities/presentation";
import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset";

export const prerender = false;

Astro.cache.set({ maxAge: 86400 });
export const getStaticPaths = localeStaticPaths;

const ext = render(
generic("open"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
---
import Layout from "../layouts/Layout.astro";
import Layout from "../../layouts/Layout.astro";

import { m } from "../paraglide/messages.js";
import { m } from "../../paraglide/messages.js";
import { localeStaticPaths } from "../../utilities/locale";

export const prerender = false;

Astro.cache.set({ maxAge: 86400 });
export const getStaticPaths = localeStaticPaths;
---

<Layout page="page" title={m.imprint_title()}>
Expand Down
12 changes: 6 additions & 6 deletions src/pages/index.astro → src/pages/[...locale]/index.astro
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
import SearchBox from "../components/SearchBox.astro";
import Layout from "../layouts/Layout.astro";
import SearchBox from "../../components/SearchBox.astro";
import Layout from "../../layouts/Layout.astro";

import { getDeviceCount } from "../io/device";
import { m } from "../paraglide/messages.js";
import { getLocale, localizeHref } from "../paraglide/runtime.js";
import { cachePolicy } from "../utilities/cache";
import { getDeviceCount } from "../../io/device";
import { m } from "../../paraglide/messages.js";
import { getLocale, localizeHref } from "../../paraglide/runtime.js";
import { cachePolicy } from "../../utilities/cache";

export const prerender = false;

Expand Down
11 changes: 7 additions & 4 deletions src/pages/surveys.astro → src/pages/[...locale]/surveys.astro
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
---
import Layout from "../layouts/Layout.astro";
import Layout from "../../layouts/Layout.astro";

import { m } from "../paraglide/messages.js";
import { generic, render } from "../utilities/presentation";
import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset";
import { m } from "../../paraglide/messages.js";
import { generic, render } from "../../utilities/presentation";
import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset";

// The active edition comes from `?survey=`, so this page stays server-rendered.
export const prerender = false;

Astro.cache.set({ maxAge: 86400 });

Expand Down
18 changes: 18 additions & 0 deletions src/pages/config.js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { CSR_API_AUTHORITY } from "astro:env/server";
import type { APIRoute } from "astro";

// Server-rendered so prerendered pages still get the runtime value
export const prerender = false;

export const GET: APIRoute = () => {
const headers = new Headers({
"content-type": "text/javascript; charset=utf-8",
// only changes on redeploy or restart
"cache-control": "public, max-age=300",
});

return new Response(
`window.__API_AUTHORITY__ = ${JSON.stringify(CSR_API_AUTHORITY)};\n`,
{ headers },
);
};
Comment thread
PhilipTrauner marked this conversation as resolved.
4 changes: 4 additions & 0 deletions src/utilities/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { IoHeadersCaching } from "../io";

/**
* Turns the API caching headers into `Astro.cache.set()` options. They only
* drive Astro's in-process cache, the response has no `Cache-Control` header.
*/
export const cachePolicy = (headers: IoHeadersCaching) => ({
// exact optional property buffoonery
...(typeof headers["cache-control"]?.maxAge !== "undefined"
Expand Down
Loading
Loading