CI Pipeline / backend-test (pull_request) Successful in 9m46s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / backend-test (push) Successful in 9m58s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / quality-gate (pull_request) Successful in 2s
The CSR shell served to regular browsers caused a visible blank-to-content flash: the browser painted an empty <app-root>, then waited for Angular to bootstrap and lazy-load the route before any content appeared. All requests now route through the Angular SSR engine (appEngine.handle()), which fetches fresh station config from the API via APP_INITIALIZER before rendering. The first paint carries real content, colors, and assets.
292 lines
11 KiB
TypeScript
292 lines
11 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';
|
|
|
|
/**
|
|
* 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 CSR shell path (index.csr.html).
|
|
*
|
|
* IMPORTANT: We MUST prefer index.csr.html over index.html.
|
|
* The Angular build with prerender config produces both:
|
|
* - index.html: prerendered at build time with stale DEFAULT_CONFIG baked in
|
|
* - index.csr.html: pure CSR shell with empty <app-root> — Angular hydrates client-side
|
|
*
|
|
* Serving index.html to regular users causes the flash: the prerendered DOM
|
|
* shows stale build-time content until Angular re-renders with fresh data.
|
|
* Even with window.__STATION_CONFIG injection, the prerendered innerHTML inside
|
|
* <app-root> still shows old content on the first paint.
|
|
*
|
|
* 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
|
|
// CSR shell first — avoid prerendered index.html with stale content
|
|
const productionPaths = [
|
|
'/usr/share/nginx/html/index.csr.html',
|
|
'/usr/share/nginx/html/index.html',
|
|
'/var/www/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 a gradient CSS value from three color stops. */
|
|
function gradient(from: string, mid: string, to: string): string {
|
|
return `linear-gradient(135deg, ${from} 0%, ${mid} 40%, ${to} 100%)`;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Includes both flat color properties AND derived gradient variables
|
|
* (--sky-gradient, --sunset-gradient, --mixed-gradient) so components
|
|
* using var() for backgrounds render correctly on the very first paint.
|
|
*/
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
// Derived gradients — mirror the same formulas ThemeService.apply() uses.
|
|
// These must be injected server-side; otherwise components referencing
|
|
// var(--sky-gradient) etc. fall back to their SCSS build-time defaults
|
|
// on first paint, causing a visible color flash.
|
|
const cp = (field: keyof StationConfig): string => (config as any)[field] || '';
|
|
props.push(`--sky-gradient: ${gradient(cp('color_primary_dark'), cp('color_primary'), cp('color_primary_light'))}`);
|
|
props.push(`--sunset-gradient: ${gradient(cp('color_accent_dark'), cp('color_accent'), cp('color_accent_light'))}`);
|
|
props.push(`--mixed-gradient: linear-gradient(135deg, ${cp('color_primary_dark')} 0%, ${cp('color_primary')} 50%, ${cp('color_accent')} 100%)`);
|
|
|
|
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 {
|
|
// ── SSR rendering for all users ────────────────────
|
|
// Every user — spiders and regular browsers — gets full
|
|
// Angular SSR rendering. The SSR engine (main.server.ts)
|
|
// fetches fresh station config from the API via APP_INITIALIZER
|
|
// before rendering, so the first paint carries real content,
|
|
// colors, and assets. No CSR shell flash, no blank gap.
|
|
//
|
|
// NOTE: The spider-only SSR gate was removed because serving
|
|
// a CSR shell (empty <app-root>) to regular browsers caused
|
|
// a visible blank-to-content transition. The SSR engine is
|
|
// fast enough to handle regular traffic.
|
|
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 };
|