import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse, } from '@angular/ssr/node'; import * as fs from 'node:fs'; import * as http from 'node:http'; 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(); /** * 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', '/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.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]; } /** * Serve the static CSR app shell from disk. * * Regular browser users get the prerendered HTML shell — Angular hydrates * 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 { const indexPath = browserIndexPath(); try { const content = fs.readFileSync(indexPath, 'utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('X-Render-Mode', 'CSR'); res.end(content); } catch { res.statusCode = 500; res.end('Internal server error — index.html not found'); } } 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 static CSR app shell from disk, // which hydrates client-side and fetches fresh data from the API. const userAgent = getUserAgent(req.headers); if (!isSpider(userAgent)) { serveStaticShell(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 };