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
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:
1 parent
9561339cf5
commit
e44f27d1cd
4 files changed
+221
-22
No files matched your search
+6
-3
@@ -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
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in new issue
Block a user