fix(ssr): inject fresh station config into CSR shell to eliminate flash
CI Pipeline / backend-test (push) Successful in 18m48s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 20m7s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 4s

The CSR shell previously rendered with hardcoded DEFAULT_CONFIG values,
then the client-side API call replaced them 50-500ms later, causing a
visible 'flash of stale content' (wrong colors, wrong station name).

Fix: SSR server fetches live station config from the backend API before
serving the CSR shell, injecting it as:
- Inline <script> seeding window.__STATION_CONFIG (client reads sync)
- Inline <style> setting CSS custom properties (correct colors first paint)

Also adds no-cache Cache-Control headers on HTML page responses to
prevent browsers from caching stale content.

StationConfigService now reads window.__STATION_CONFIG on construction
instead of always starting with DEFAULT_CONFIG.
This commit is contained in:
robot committed 2026-08-09 00:53:50 +00:00
1 parent 9561339cf5
commit e44f27d1cd
4 files changed
+221 -22

No files matched your search

+6 -3
View File
@@ -78,9 +78,8 @@ http {
} }
# Proxy all page requests to the Angular SSR server. # Proxy all page requests to the Angular SSR server.
# The SSR server (AngularNodeAppEngine) handles both RenderMode.Server # The SSR server handles both spider SSR rendering and serves CSR shells
# routes (full on-demand rendering with fresh API data) and serves # to regular users, with fresh station config injected at runtime.
# prerendered static HTML for RenderMode.Prerender routes.
location / { location / {
proxy_pass http://ssr_backend; proxy_pass http://ssr_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -92,6 +91,10 @@ http {
# SSR responses should not be cached by intermediate proxies # SSR responses should not be cached by intermediate proxies
proxy_cache off; proxy_cache off;
add_header X-Powered-By "Angular SSR" always; 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;
} }
} }
} }
+51 -4
View File
@@ -28,7 +28,9 @@ vi.mock('node:path', () => ({
})); }));
// Mock the engine manifest that only exists after ng build // 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', () => { describe('SSR server — server.ts', () => {
it('should export a request handler', async () => { it('should export a request handler', async () => {
@@ -36,12 +38,12 @@ describe('SSR server — server.ts', () => {
expect(mod.requestHandler).toBeDefined(); 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 fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8'); const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain('isSpider'); expect(content).toContain('isSpider');
expect(content).toContain('getUserAgent'); expect(content).toContain('getUserAgent');
expect(content).toContain('serveStaticShell'); expect(content).toContain('serveCsrShell');
expect(content).toContain('spider_detection'); expect(content).toContain('spider_detection');
}); });
@@ -50,7 +52,45 @@ describe('SSR server — server.ts', () => {
const content = fs.readFileSync('./server.ts', 'utf-8'); const content = fs.readFileSync('./server.ts', 'utf-8');
// Verify the spider detection gate exists // Verify the spider detection gate exists
expect(content).toContain("if (!isSpider(userAgent))"); 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('location /api/');
expect(content).toContain('__API_UPSTREAM__'); 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', () => { describe('main.server.ts — SSR API base URL injection', () => {
+152 -14
View File
@@ -6,6 +6,7 @@ import {
} from '@angular/ssr/node'; } from '@angular/ssr/node';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https';
import * as path from 'node:path'; import * as path from 'node:path';
import { isSpider, getUserAgent } from './spider_detection'; import { isSpider, getUserAgent } from './spider_detection';
@@ -23,6 +24,53 @@ import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
const appEngine = new AngularNodeAppEngine(); 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. * Resolve the static browser index.html path.
* *
@@ -34,13 +82,16 @@ function browserIndexPath(): string {
// Production path — Dockerfile copies browser output here // Production path — Dockerfile copies browser output here
const productionPaths = [ const productionPaths = [
'/usr/share/nginx/html/index.html', '/usr/share/nginx/html/index.html',
'/usr/share/nginx/html/index.csr.html',
'/var/www/html/index.html', '/var/www/html/index.html',
]; ];
// Local dev path — relative to the compiled server directory // Local dev path — relative to the compiled server directory
const dir = typeof __dirname !== 'undefined' ? __dirname : path.dirname(new URL(import.meta.url).pathname); const dir = typeof __dirname !== 'undefined' ? __dirname : path.dirname(new URL(import.meta.url).pathname);
const localPaths = [ const localPaths = [
path.join(dir, 'browser', 'index.csr.html'),
path.join(dir, 'browser', 'index.html'), path.join(dir, 'browser', 'index.html'),
path.join(dir, '..', 'browser', 'index.csr.html'),
path.join(dir, '..', 'browser', 'index.html'), path.join(dir, '..', 'browser', 'index.html'),
]; ];
@@ -57,23 +108,110 @@ function browserIndexPath(): string {
} }
/** /**
* Serve the static CSR app shell from disk. * Build an inline `<script>` that seeds `window.__STATION_CONFIG` so the
* * client-side StationConfigService can read fresh config synchronously
* Regular browser users get the prerendered HTML shell — Angular hydrates * during bootstrap — no async API round-trip needed, no DEFAULT_CONFIG flash.
* 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.
*/ */
function serveStaticShell(res: http.ServerResponse): void { function buildConfigScript(config: StationConfig): string {
const indexPath = browserIndexPath(); const json = JSON.stringify(config);
return `<script>window.__STATION_CONFIG=${json};</script>`;
}
/**
* 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 `<style>${css}</style>`;
}
/**
* 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<StationConfig | null> {
// 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 { try {
const content = fs.readFileSync(indexPath, 'utf-8'); const client = baseUrl.startsWith('https') ? https : http;
res.setHeader('Content-Type', 'text/html; charset=utf-8'); const data = await new Promise<string>((resolve, reject) => {
res.setHeader('X-Render-Mode', 'CSR'); const req = client.get(url, {
res.end(content); 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 <script> before </head>
* 4. Inject theme CSS custom properties as an inline <style> before </head>
* 5. The client-side StationConfigService reads window.__STATION_CONFIG
* synchronously on construction — it skips the API call when seeded.
*/
async function serveCsrShell(res: http.ServerResponse): Promise<void> {
const indexPath = browserIndexPath();
let html: string;
try {
html = fs.readFileSync(indexPath, 'utf-8');
} catch { } catch {
res.statusCode = 500; res.statusCode = 500;
res.end('Internal server error — index.html not found'); res.end('Internal server error — index.html not found');
return;
} }
// Attempt to fetch fresh config to inject into the shell.
const config = await fetchStationConfig();
let enhanced = html;
if (config) {
const script = buildConfigScript(config);
const style = buildThemeStyle(config);
const injection = `${style}\n${script}`;
enhanced = html.replace('</head>', `${injection}</head>`);
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('X-Render-Mode', 'CSR');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.end(enhanced);
} }
const requestHandler = createNodeRequestHandler(async (req, res, next) => { const requestHandler = createNodeRequestHandler(async (req, res, next) => {
@@ -97,11 +235,11 @@ if (isMainModule(import.meta.url)) {
try { try {
// ── Spider detection gate ────────────────────────── // ── Spider detection gate ──────────────────────────
// Only search engine spiders get full SSR rendering. // Only search engine spiders get full SSR rendering.
// Regular users receive the static CSR app shell from disk, // Regular users receive the CSR app shell from disk,
// which hydrates client-side and fetches fresh data from the API. // enhanced with fresh station config injected at runtime.
const userAgent = getUserAgent(req.headers); const userAgent = getUserAgent(req.headers);
if (!isSpider(userAgent)) { if (!isSpider(userAgent)) {
serveStaticShell(res); await serveCsrShell(res);
return; return;
} }
+12 -1
View File
@@ -74,7 +74,18 @@ export class StationConfigService {
private themeService = inject(ThemeService); private themeService = inject(ThemeService);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`; private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
readonly config = signal<StationConfig>(DEFAULT_CONFIG); /**
* Read pre-seeded config injected by the server at runtime.
* The SSR server fetches fresh station config and injects it as
* `window.__STATION_CONFIG` before the Angular bundle loads.
* This eliminates the DEFAULT_CONFIG flash: the client bootstraps
* with real data instead of hardcoded defaults.
*/
readonly config = signal<StationConfig>(
typeof window !== 'undefined' && (window as any).__STATION_CONFIG
? (window as any).__STATION_CONFIG as StationConfig
: DEFAULT_CONFIG,
);
/** Load station config from the API. Falls back to defaults on error. */ /** Load station config from the API. Falls back to defaults on error. */
async load(): Promise<void> { async load(): Promise<void> {