Files
kmtnflower/server.ts
T
robot e44f27d1cd
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
fix(ssr): inject fresh station config into CSR shell to eliminate flash
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.
2026-08-09 00:53:50 +00:00

263 lines
9.1 KiB
TypeScript

import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} 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';
/**
* Manually import and register the Angular app engine manifest.
* The Angular 21 builder generates angular-app-engine-manifest.mjs at build time
* but does NOT automatically inject the import into server.mjs.
* Without this, AngularNodeAppEngine fails with "Angular app engine manifest is not set"
* and every SSR request returns 404.
*/
// @ts-ignore — generated at build time by @angular/build
import engineManifest from './angular-app-engine-manifest.mjs';
import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
ɵsetAngularAppEngineManifest(engineManifest);
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.
*
* Production (Docker): browser files are copied to /usr/share/nginx/html/
* Local dev (ng build): browser files live in dist/radio-station/browser/
* Try each candidate in order and return the first that exists.
*/
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'),
];
const allCandidates = [...productionPaths, ...localPaths];
for (const candidate of allCandidates) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// ignore — keep trying
}
}
// Return the first candidate so the error path has a sensible value to log
return allCandidates[0];
}
/**
* Build an inline `<script>` that seeds `window.__STATION_CONFIG` so the
* client-side StationConfigService can read fresh config synchronously
* during bootstrap — no async API round-trip needed, no DEFAULT_CONFIG flash.
*/
function buildConfigScript(config: StationConfig): string {
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 {
const client = baseUrl.startsWith('https') ? https : http;
const data = await new Promise<string>((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 <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 {
res.statusCode = 500;
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) => {
try {
const response = await appEngine.handle(req);
if (response) {
await writeResponseToNodeResponse(response, res);
} else {
next();
}
} catch (error) {
next(error);
}
});
if (isMainModule(import.meta.url)) {
console.log('Node.js SSR server ready');
const server = http.createServer();
server.on('request', async (req, res) => {
try {
// ── Spider detection gate ──────────────────────────
// Only search engine spiders get full SSR rendering.
// Regular users receive the CSR app shell from disk,
// enhanced with fresh station config injected at runtime.
const userAgent = getUserAgent(req.headers);
if (!isSpider(userAgent)) {
await serveCsrShell(res);
return;
}
// Spider — render full HTML via Angular SSR with live API data
await requestHandler(req, res, () => {
res.statusCode = 404;
res.end('Not Found');
});
} catch (error) {
console.error('SSR error:', error);
res.statusCode = 500;
res.end('Internal server error');
}
});
server.listen(4200, () => {
console.log('SSR server listening on http://localhost:4200');
});
}
export { requestHandler };