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