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 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 — 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 * 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 ``; } /** 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 ``; } /** * 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 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