diff --git a/nginx.conf.template b/nginx.conf.template
index 0d61aba..bec1d3d 100644
--- a/nginx.conf.template
+++ b/nginx.conf.template
@@ -78,9 +78,8 @@ http {
}
# Proxy all page requests to the Angular SSR server.
- # The SSR server (AngularNodeAppEngine) handles both RenderMode.Server
- # routes (full on-demand rendering with fresh API data) and serves
- # prerendered static HTML for RenderMode.Prerender routes.
+ # The SSR server handles both spider SSR rendering and serves CSR shells
+ # to regular users, with fresh station config injected at runtime.
location / {
proxy_pass http://ssr_backend;
proxy_set_header Host $host;
@@ -92,6 +91,10 @@ http {
# SSR responses should not be cached by intermediate proxies
proxy_cache off;
add_header X-Powered-By "Angular SSR" always;
+
+ # Prevent browsers from caching HTML pages — content changes frequently
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header Pragma "no-cache" always;
}
}
}
diff --git a/server.spec.ts b/server.spec.ts
index 9aa4b78..5ca3bbe 100644
--- a/server.spec.ts
+++ b/server.spec.ts
@@ -28,7 +28,9 @@ vi.mock('node:path', () => ({
}));
// Mock the engine manifest that only exists after ng build
-vi.mock('./angular-app-engine-manifest.mjs', () => ({}));
+vi.mock('./angular-app-engine-manifest.mjs', () => ({
+ default: {},
+}));
describe('SSR server — server.ts', () => {
it('should export a request handler', async () => {
@@ -36,12 +38,12 @@ describe('SSR server — server.ts', () => {
expect(mod.requestHandler).toBeDefined();
});
- it('should have spider detection imports and serveStaticShell', async () => {
+ it('should have spider detection imports and serveCsrShell', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain('isSpider');
expect(content).toContain('getUserAgent');
- expect(content).toContain('serveStaticShell');
+ expect(content).toContain('serveCsrShell');
expect(content).toContain('spider_detection');
});
@@ -50,7 +52,45 @@ describe('SSR server — server.ts', () => {
const content = fs.readFileSync('./server.ts', 'utf-8');
// Verify the spider detection gate exists
expect(content).toContain("if (!isSpider(userAgent))");
- expect(content).toContain('serveStaticShell(res)');
+ expect(content).toContain('serveCsrShell(res)');
+ });
+
+ it('should inject fresh station config as window.__STATION_CONFIG', async () => {
+ const fs = require('fs');
+ const content = fs.readFileSync('./server.ts', 'utf-8');
+ expect(content).toContain('window.__STATION_CONFIG');
+ expect(content).toContain('buildConfigScript');
+ expect(content).toContain('fetchStationConfig');
+ });
+
+ it('should inject inline theme CSS custom properties', async () => {
+ const fs = require('fs');
+ const content = fs.readFileSync('./server.ts', 'utf-8');
+ expect(content).toContain('buildThemeStyle');
+ expect(content).toContain('COLOR_MAP');
+ expect(content).toContain('--color-primary');
+ expect(content).toContain(':root{');
+ });
+
+ it('should serveCsrShell be async and accept (res)', async () => {
+ const fs = require('fs');
+ const content = fs.readFileSync('./server.ts', 'utf-8');
+ expect(content).toMatch(/async function serveCsrShell\s*\(\s*res/);
+ });
+
+ it('should fetch station config from SSR_API_BASE_URL with 2s timeout', async () => {
+ const fs = require('fs');
+ const content = fs.readFileSync('./server.ts', 'utf-8');
+ expect(content).toContain('SSR_API_BASE_URL');
+ expect(content).toContain('api/station-config');
+ expect(content).toContain('setTimeout(2000');
+ });
+
+ it('should set no-cache headers on CSR shell responses', async () => {
+ const fs = require('fs');
+ const content = fs.readFileSync('./server.ts', 'utf-8');
+ expect(content).toContain("Cache-Control");
+ expect(content).toContain("no-cache, no-store, must-revalidate");
});
});
@@ -274,6 +314,13 @@ describe('nginx.conf.template — SSR proxy chain', () => {
expect(content).toContain('location /api/');
expect(content).toContain('__API_UPSTREAM__');
});
+
+ it('should add Cache-Control no-cache headers on HTML page responses', () => {
+ const fs = require('fs');
+ const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
+ expect(content).toContain('add_header Cache-Control "no-cache, no-store, must-revalidate" always');
+ expect(content).toContain('add_header Pragma "no-cache" always');
+ });
});
describe('main.server.ts — SSR API base URL injection', () => {
diff --git a/server.ts b/server.ts
index 19fc4d2..2d5c3f3 100644
--- a/server.ts
+++ b/server.ts
@@ -6,6 +6,7 @@ import {
} from '@angular/ssr/node';
import * as fs from 'node:fs';
import * as http from 'node:http';
+import * as https from 'node:https';
import * as path from 'node:path';
import { isSpider, getUserAgent } from './spider_detection';
@@ -23,6 +24,53 @@ import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
const appEngine = new AngularNodeAppEngine();
+/**
+ * Station config shape — mirrors the backend API response.
+ * Only the color fields are used for theme injection; the rest are
+ * passed through verbatim for the client-side StationConfigService.
+ */
+interface StationConfig {
+ color_primary: string;
+ color_primary_light: string;
+ color_primary_dark: string;
+ color_primary_muted: string;
+ color_accent: string;
+ color_accent_light: string;
+ color_accent_dark: string;
+ color_accent_muted: string;
+ color_background: string;
+ color_text: string;
+ color_success: string;
+ color_danger: string;
+ color_info: string;
+ color_white: string;
+ color_light: string;
+ color_medium: string;
+ color_black: string;
+ [key: string]: unknown;
+}
+
+/** CSS custom property names mapped from StationConfig color fields. */
+const COLOR_MAP: ReadonlyArray<{ field: keyof StationConfig; prop: string }> = [
+ { field: 'color_primary', prop: '--color-primary' },
+ { field: 'color_primary_light', prop: '--color-primary-light' },
+ { field: 'color_primary_dark', prop: '--color-primary-dark' },
+ { field: 'color_primary_muted', prop: '--color-primary-muted' },
+ { field: 'color_accent', prop: '--color-accent' },
+ { field: 'color_accent_light', prop: '--color-accent-light' },
+ { field: 'color_accent_dark', prop: '--color-accent-dark' },
+ { field: 'color_accent_muted', prop: '--color-accent-muted' },
+ { field: 'color_background', prop: '--color-cream' },
+ { field: 'color_text', prop: '--color-dark' },
+ { field: 'color_success', prop: '--color-success' },
+ { field: 'color_danger', prop: '--color-danger' },
+ { field: 'color_info', prop: '--color-info' },
+ { field: 'color_white', prop: '--color-white' },
+ { field: 'color_light', prop: '--color-light' },
+ { field: 'color_medium', prop: '--color-medium' },
+ { field: 'color_black', prop: '--color-black' },
+];
+
/**
* Resolve the static browser index.html path.
*
@@ -34,13 +82,16 @@ function browserIndexPath(): string {
// Production path — Dockerfile copies browser output here
const productionPaths = [
'/usr/share/nginx/html/index.html',
+ '/usr/share/nginx/html/index.csr.html',
'/var/www/html/index.html',
];
// Local dev path — relative to the compiled server directory
const dir = typeof __dirname !== 'undefined' ? __dirname : path.dirname(new URL(import.meta.url).pathname);
const localPaths = [
+ path.join(dir, 'browser', 'index.csr.html'),
path.join(dir, 'browser', 'index.html'),
+ path.join(dir, '..', 'browser', 'index.csr.html'),
path.join(dir, '..', 'browser', 'index.html'),
];
@@ -57,23 +108,110 @@ function browserIndexPath(): string {
}
/**
- * Serve the static CSR app shell from disk.
- *
- * Regular browser users get the prerendered HTML shell — Angular hydrates
- * client-side and fetches fresh config via the API. This avoids the SSR
- * overhead for every human request while still delivering a fast first paint.
+ * Build an inline ``;
+}
+
+/**
+ * Build inline CSS custom properties on `:root` so the first paint carries
+ * the correct color scheme — eliminates the color flash that occurs when
+ * ThemeService.apply() runs asynchronously after the API call.
+ */
+function buildThemeStyle(config: StationConfig): string {
+ const props: string[] = [];
+
+ for (const { field, prop } of COLOR_MAP) {
+ const value = (config as any)[field];
+ if (value && typeof value === 'string') {
+ props.push(`${prop}: ${value}`);
+ }
+ }
+
+ if (props.length === 0) return '';
+
+ const css = `:root{${props.join(';')}}`;
+ return ``;
+}
+
+/**
+ * Fetch fresh station config from the backend API.
+ * This runs server-side — it goes through the nginx proxy chain to the
+ * upstream FastAPI backend. On failure we fall back to serving the shell
+ * unmodified (client-side fetch picks up the slack).
+ */
+async function fetchStationConfig(): Promise {
+ // SSR_API_BASE_URL points at nginx (localhost:80) which proxies /api/ to
+ // the upstream FastAPI backend. This avoids cross-origin issues and reuses
+ // the same proxy chain the SSR engine itself would use.
+ const baseUrl = process.env['SSR_API_BASE_URL'] || 'http://localhost:80';
+ const url = `${baseUrl}/api/station-config`;
+
try {
- const content = fs.readFileSync(indexPath, 'utf-8');
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
- res.setHeader('X-Render-Mode', 'CSR');
- res.end(content);
+ const client = baseUrl.startsWith('https') ? https : http;
+ const data = await new Promise((resolve, reject) => {
+ const req = client.get(url, {
+ headers: { 'Accept': 'application/json' },
+ }, (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
+ res.on('error', reject);
+ });
+ req.on('error', reject);
+ req.setTimeout(2000, () => { req.destroy(new Error('Station config fetch timed out')); });
+ });
+ return JSON.parse(data) as StationConfig;
+ } catch (err) {
+ console.warn(`[SSR] Failed to fetch station config from ${url}: ${err}`);
+ return null;
+ }
+}
+
+/**
+ * Serve the CSR app shell, enhanced with fresh station config injected at
+ * runtime so the client bootstraps with real data — no DEFAULT_CONFIG flash.
+ *
+ * Strategy:
+ * 1. Read CSR shell HTML from disk
+ * 2. Fetch live station config from the backend API
+ * 3. Inject window.__STATION_CONFIG as an inline