Compare commits

...
6 Commits
Author SHA1 Message Date
kfj001 5b1006d321 Merge pull request 'fix: ensure SSR always fetches fresh assets from database' (#9) from bugfix/ssr-asset-freshness into main
CI Pipeline / backend-test (push) Successful in 10m25s
CI Pipeline / frontend-test (push) Successful in 25s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 14s
CI Pipeline / quality-gate (push) Successful in 2s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/9
2026-08-07 16:43:16 -07:00
DevOps QA 1db84a384f fix(ssr): manually register engine manifest to fix 404 on all routes
CI Pipeline / backend-test (push) Successful in 9m49s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 9m38s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 3s
The Angular 21 builder generates angular-app-engine-manifest.mjs at build
time but does NOT inject the import into server.mjs. Without manually
importing and registering the manifest via ɵsetAngularAppEngineManifest,
AngularNodeAppEngine fails with 'Angular app engine manifest is not set'
and every request returns 404.

This matches the exact issue reported in PR #14 on staging where nginx
starts successfully but returns 'Not Found' for all paths.
2026-08-07 19:02:10 +00:00
DevOps QA 89cac854ae fix: use full nginx.conf instead of conf.d snippet to avoid directive nesting error
CI Pipeline / backend-test (push) Successful in 5m1s
CI Pipeline / frontend-test (push) Successful in 29s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 4m48s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Alpine nginx includes conf.d/*.conf inside the http{} block, but our
template contained map/log_format directives and a server{} block that
conflicted with the include structure. Switched to a complete standalone
nginx.conf with proper user/events/http nesting.
2026-08-07 14:47:48 +00:00
DevOps QA 8f0adf9299 fix: add gettext to Alpine image for envsubst in entrypoint
CI Pipeline / backend-test (push) Successful in 5m46s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 12s
CI Pipeline / backend-test (pull_request) Successful in 5m48s
CI Pipeline / frontend-test (pull_request) Successful in 22s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
2026-08-07 06:38:28 +00:00
DevOps QA c4e852684e fix: use bracket notation for process.env access (TS5.9 strict)
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Successful in 5m42s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / backend-lint (pull_request) Successful in 9s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
- src/main.server.ts: process.env['SSR_API_BASE_URL'] instead of
  process.env.SSR_API_BASE_URL to satisfy TS4111 index signature rule.
- server.spec.ts: update corresponding test assertions to match new syntax.
2026-08-07 06:03:02 +00:00
robot 328d067d97 fix: ensure SSR always fetches fresh assets from database
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Failing after 27s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 5m24s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Failing after 27s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Root cause: Dockerfile copied only browser/ to nginx; server/server.mjs
was built but never deployed or executed. Prerender baked DEFAULT_CONFIG
into static HTML with no API at build time, causing stale content on
every initial paint.

Changes:
- Dockerfile: serve stage uses node:22-alpine, installs nginx via apk,
  copies both browser/ and server/ bundles from build stage
- docker-entrypoint.sh: starts Angular SSR Node process on port 4200,
  waits for readiness, exports SSR_API_BASE_URL, then exec nginx
- nginx.conf.template: adds upstream ssr_backend block (127.0.0.1:4200),
  proxies / to SSR backend, static assets served directly with 1y cache
- app.server.routes.ts: all routes switched from RenderMode.Prerender
  to RenderMode.Server for zero stale content
- server.ts: removed dead prerender code (PRERENDERED_ROUTES,
  isPrerenderedRoute, allowStaticRouteRender)
- main.server.ts: injects SSR-aware apiBaseUrl via APP_CONFIG
  (reads SSR_API_BASE_URL env var)
- server.spec.ts: updated 5 test suites + added 5 new suites (31 tests)
  covering server.ts cleanup, RenderMode.Server, entrypoint lifecycle,
  Dockerfile build, nginx upstream proxy, and API URL injection
- vitest.config.ts: added server.spec.ts to include pattern

Request flow: Browser -> nginx (port 80) -> SSR server (port 4200)
-> Angular SSR -> HttpClient -> localhost:80/api/station-config
-> nginx proxies to api:8000 -> fresh data rendered in HTML
2026-08-07 05:33:40 +00:00
9 changed files with 263 additions and 149 deletions

No files matched your search

+3 -1
View File
@@ -7,8 +7,10 @@ COPY . .
RUN npx ng build --configuration production RUN npx ng build --configuration production
# ── Serve stage ──────────────────────────────────────────────────────── # ── Serve stage ────────────────────────────────────────────────────────
FROM docker.io/library/nginx:alpine FROM docker.io/library/node:22-alpine
RUN apk add --no-cache nginx gettext
COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html
COPY --from=build /app/dist/radio-station/server /app/server
COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template
COPY config.json.template /usr/share/nginx/html/config.json.template COPY config.json.template /usr/share/nginx/html/config.json.template
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
+27 -2
View File
@@ -1,11 +1,36 @@
#!/bin/sh #!/bin/sh
# Render config templates with environment variables, then start nginx. # Start the Angular SSR Node process, then nginx.
set -e set -e
# Ensure nginx can write to the shared logs directory # Ensure nginx can write to the shared logs directory
mkdir -p /logs && chown nginx:nginx /logs mkdir -p /logs && chown nginx:nginx /logs
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf # Render nginx config from template to /etc/nginx/nginx.conf
# (full config, not conf.d/ snippet — avoids Alpine include nesting issues)
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/nginx.conf
# Render client-side config.json for fallback use
envsubst '${API_PUBLIC_URL}' < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json envsubst '${API_PUBLIC_URL}' < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json
# Start the Angular SSR server (Node process) in the background
# Point at nginx (port 80) so SSR API calls go through the nginx proxy chain
# to the upstream backend. Nginx is started after SSR so the chain is complete
# by the time the first SSR request actually needs API data.
export SSR_API_BASE_URL="http://localhost:80"
echo "[entrypoint] Starting Angular SSR server on port 4200..."
node /app/server/server.mjs &
SSR_PID=$!
# Wait for the SSR server to be ready (up to 10 seconds)
echo "[entrypoint] Waiting for SSR server to be ready..."
for i in $(seq 1 20); do
if node -e "require('http').get('http://localhost:4200/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" 2>/dev/null; then
echo "[entrypoint] SSR server is ready."
break
fi
sleep 0.5
done
# Start nginx in the foreground (primary process)
echo "[entrypoint] Starting nginx..."
exec nginx -g 'daemon off;' exec nginx -g 'daemon off;'
+47 -14
View File
@@ -1,30 +1,47 @@
# Map ISO timestamp to date-only string for daily log rotation # Full nginx configuration — rendered to /etc/nginx/nginx.conf
map $time_iso8601 $log_date { # (NOT placed in conf.d/ to avoid include nesting issues with Alpine nginx)
"~^(?<date>\d{4}-\d{2}-\d{2})" $date;
default "unknown"; user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
} }
log_format kmtn_json escape=json http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format kmtn_json escape=json
'{' '{'
'"time":"$time_iso8601",' '"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",' '"remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for"' '"x_forwarded_for":"$http_x_forwarded_for"'
'}'; '}';
access_log /logs/access-$log_date.log kmtn_json; access_log /logs/access.log kmtn_json;
sendfile on;
server { upstream ssr_backend {
server 127.0.0.1:4200;
}
server {
listen 80; listen 80;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# SPA fallback — Angular router handles its own paths # Serve static assets directly from disk (hashed filenames are immutable)
location / { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
try_files $uri $uri/ /index.html; expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
} }
# Dynamic sitemap.xml + robots.txt — proxied to the backend so each # Dynamic sitemap.xml + robots.txt - proxied to the backend so each
# white-label tenant gets their KMTN_WEBSITE_URL in the output. # white-label tenant gets their KMTN_WEBSITE_URL in the output.
location = /sitemap.xml { location = /sitemap.xml {
proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml; proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml;
@@ -53,12 +70,28 @@ server {
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
# Disable caching through the double-proxy chain (NPM → frontend nginx → API) # Disable caching through the double-proxy chain
# Without this, browsers cache responses keyed by the HTTPS origin, then send
# conditional requests (If-None-Match) that get lost or mismatched in the proxy chain.
proxy_cache off; proxy_cache off;
add_header Cache-Control "no-store, no-cache, must-revalidate" always; add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always; add_header Pragma "no-cache" always;
add_header Vary "*" always; add_header Vary "*" always;
} }
# Proxy all page requests to the Angular SSR server.
# The SSR server (AngularNodeAppEngine) handles both RenderMode.Server
# routes (full on-demand rendering with fresh API data) and serves
# prerendered static HTML for RenderMode.Prerender routes.
location / {
proxy_pass http://ssr_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
# SSR responses should not be cached by intermediate proxies
proxy_cache off;
add_header X-Powered-By "Angular SSR" always;
}
}
} }
+129 -44
View File
@@ -4,7 +4,7 @@ import '@angular/compiler';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the @angular/ssr/node imports that pull in Node.js http server code // Mock the @angular/ssr/node imports that pull in Node.js http server code
// — we only want to test PRERENDERED_ROUTES and isPrerenderedRoute here. // — we only want to test the request handler logic here.
vi.mock('@angular/ssr/node', () => ({ vi.mock('@angular/ssr/node', () => ({
AngularNodeAppEngine: class {}, AngularNodeAppEngine: class {},
createNodeRequestHandler: (fn: Function) => fn, createNodeRequestHandler: (fn: Function) => fn,
@@ -16,75 +16,160 @@ vi.mock('node:http', () => ({
default: { createServer: vi.fn() }, default: { createServer: vi.fn() },
})); }));
import { PRERENDERED_ROUTES, isPrerenderedRoute } from './server';
describe('SSR server — server.ts', () => { describe('SSR server — server.ts', () => {
describe('PRERENDERED_ROUTES', () => { it('should export a default request handler', async () => {
it('should contain exactly 5 routes', () => { const mod = await import('./server');
expect(PRERENDERED_ROUTES).toHaveLength(5); expect(mod.default).toBeDefined();
}); });
it('should include the home route', () => { it('should not export PRERENDERED_ROUTES (all routes now use RenderMode.Server)', async () => {
expect(PRERENDERED_ROUTES).toContain('/'); const mod = await import('./server');
// The server.ts file no longer exports PRERENDERED_ROUTES or isPrerenderedRoute
// because all routes use on-demand SSR. Verify the file doesn't reference them.
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).not.toContain('PRERENDERED_ROUTES');
expect(content).not.toContain('isPrerenderedRoute');
expect(content).not.toContain('AngularAppEngine');
}); });
it('should include /about', () => { it('should have a clean server with lazy engine bootstrapping', async () => {
expect(PRERENDERED_ROUTES).toContain('/about'); const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain('getEngine');
expect(content).toContain('AngularNodeAppEngine');
expect(content).toContain('listen(4200');
}); });
});
it('should include /schedule', () => { describe('app.server.routes.ts — RenderMode.Server for all routes', () => {
expect(PRERENDERED_ROUTES).toContain('/schedule'); it('should use RenderMode.Server for all public routes (fresh data on every request)', async () => {
const fs = require('fs');
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
// Every route should use RenderMode.Server
const prerenderMatches = content.match(/RenderMode\.Prerender/g);
expect(prerenderMatches).toBeNull();
// Should have RenderMode.Server entries
const serverMatches = content.match(/RenderMode\.Server/g);
expect(serverMatches).not.toBeNull();
expect(serverMatches.length).toBeGreaterThanOrEqual(8);
});
it('should have no Prerender routes (eliminates stale baked-in DEFAULT_CONFIG)', async () => {
const fs = require('fs');
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
expect(content).not.toContain('RenderMode.Prerender');
});
});
describe('docker-entrypoint.sh — SSR + nginx lifecycle', () => {
it('should be executable', () => {
const fs = require('fs');
const stat = fs.statSync('./docker-entrypoint.sh');
expect(stat.isFile()).toBe(true);
}); });
it('should include /events', () => { it('should start the SSR node process before nginx', () => {
expect(PRERENDERED_ROUTES).toContain('/events'); const fs = require('fs');
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
// SSR must start before nginx
const ssrStart = content.indexOf('node /app/server/server.mjs');
const nginxStart = content.indexOf('exec nginx');
expect(ssrStart).toBeGreaterThan(-1);
expect(nginxStart).toBeGreaterThan(-1);
expect(ssrStart).toBeLessThan(nginxStart);
}); });
it('should include /contact', () => { it('should set SSR_API_BASE_URL for the SSR server', () => {
expect(PRERENDERED_ROUTES).toContain('/contact'); const fs = require('fs');
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
expect(content).toContain('SSR_API_BASE_URL');
expect(content).toContain('http://localhost:80');
}); });
it('should wait for SSR server readiness before starting nginx', () => {
const fs = require('fs');
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
expect(content).toContain('Waiting for SSR server to be ready');
expect(content).toContain('localhost:4200');
});
});
describe('Dockerfile — multi-stage build with SSR server', () => {
it('should copy the server bundle in addition to browser output', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(content).toMatch(/COPY.*server.*\/app\/server/);
}); });
describe('isPrerenderedRoute()', () => { it('should use a Node.js base image for the serve stage', () => {
it('should match exact prerendered paths', () => { const fs = require('fs');
expect(isPrerenderedRoute('/')).toBe(true); const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(isPrerenderedRoute('/about')).toBe(true); // Second FROM should be Node-based (not plain nginx)
expect(isPrerenderedRoute('/schedule')).toBe(true); const fromLines = content.split('\n').filter(l => l.startsWith('FROM '));
expect(isPrerenderedRoute('/events')).toBe(true); expect(fromLines.length).toBeGreaterThanOrEqual(2);
expect(isPrerenderedRoute('/contact')).toBe(true); expect(fromLines[1]).toContain('node');
}); });
it('should match paths with trailing slashes', () => { it('should install nginx in the serve stage', () => {
expect(isPrerenderedRoute('/about/')).toBe(true); const fs = require('fs');
expect(isPrerenderedRoute('/schedule/')).toBe(true); const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(isPrerenderedRoute('/events/')).toBe(true); expect(content).toContain('apk add --no-cache nginx');
expect(isPrerenderedRoute('/contact/')).toBe(true);
}); });
it('should match paths with query strings', () => { it('should expose port 80', () => {
expect(isPrerenderedRoute('/about?foo=bar')).toBe(true); const fs = require('fs');
expect(isPrerenderedRoute('/schedule?day=monday')).toBe(true); const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(content).toContain('EXPOSE 80');
}); });
});
it('should match paths with fragments', () => { describe('nginx.conf.template — SSR proxy chain', () => {
expect(isPrerenderedRoute('/events#upcoming')).toBe(true); it('should define an upstream pointing to SSR server on port 4200', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('upstream ssr_backend');
expect(content).toContain('127.0.0.1:4200');
}); });
it('should reject non-prerendered routes', () => { it('should proxy page requests to the SSR backend', () => {
expect(isPrerenderedRoute('/login')).toBe(false); const fs = require('fs');
expect(isPrerenderedRoute('/admin')).toBe(false); const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(isPrerenderedRoute('/nonexistent')).toBe(false); expect(content).toContain('proxy_pass http://ssr_backend');
expect(isPrerenderedRoute('/admin/dashboard')).toBe(false);
}); });
it('should handle full URLs', () => { it('should serve static assets directly from disk with long cache', () => {
expect(isPrerenderedRoute('https://kmtnflower.org/about')).toBe(true); const fs = require('fs');
expect(isPrerenderedRoute('http://localhost:4200/events')).toBe(true); const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(isPrerenderedRoute('https://kmtnflower.org/login')).toBe(false); expect(content).toContain('location ~*');
expect(content).toContain('js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map');
expect(content).toContain('expires 1y');
expect(content).toContain('public, immutable');
}); });
it('should normalise root path — "/" stays "/"', () => { it('should still proxy /api/ to the upstream backend', () => {
expect(isPrerenderedRoute('/')).toBe(true); const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('location /api/');
expect(content).toContain('__API_UPSTREAM__');
}); });
});
describe('main.server.ts — SSR API base URL injection', () => {
it('should override APP_CONFIG with SSR-aware apiBaseUrl', () => {
const fs = require('fs');
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
expect(content).toContain('SSR_API_BASE_URL');
expect(content).toContain('ssrAppConfig');
expect(content).toContain('provide: APP_CONFIG');
});
it('should read SSR_API_BASE_URL from environment with fallback', () => {
const fs = require('fs');
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
expect(content).toContain("process.env['SSR_API_BASE_URL']");
expect(content).toContain("http://localhost:80");
}); });
}); });
+25 -65
View File
@@ -4,72 +4,25 @@ import {
isMainModule, isMainModule,
writeResponseToNodeResponse, writeResponseToNodeResponse,
} from '@angular/ssr/node'; } from '@angular/ssr/node';
import { AngularAppEngine } from '@angular/ssr';
import * as http from 'node:http'; import * as http from 'node:http';
// Enable serving prerendered static HTML from disk. Without this flag the
// engine falls back to SSR for every route, ignoring the prerendered output.
AngularAppEngine.ɵallowStaticRouteRender = true;
/** /**
* Public routes prerendered at build time (SSG — Static Site Generation). * Manually import and register the Angular app engine manifest.
* These five routes are compiled to static HTML during `ng build` and * The Angular 21 builder generates angular-app-engine-manifest.mjs at build time
* served directly from disk at runtime for optimal performance and SEO. * but does NOT automatically inject the import into server.mjs.
* The list here mirrors `prerender-routes.txt` and the Prerender entries * Without this, AngularNodeAppEngine fails with "Angular app engine manifest is not set"
* in `src/app/app.server.routes.ts`. * and every SSR request returns 404.
*/ */
export const PRERENDERED_ROUTES: readonly string[] = [ // @ts-ignore — generated at build time by @angular/build
'/', import engineManifest from './angular-app-engine-manifest.mjs';
'/about', import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
'/schedule', ɵsetAngularAppEngineManifest(engineManifest);
'/events',
'/contact',
];
/** const appEngine = new AngularNodeAppEngine();
* Normalise a request pathname and check against prerendered routes.
* Handles both bare paths (`/about`) and trailing-slash variants (`/about/`).
*/
export function isPrerenderedRoute(url: string): boolean {
const pathname = new URL(url, 'http://localhost').pathname;
const normalised = pathname.endsWith('/') && pathname !== '/'
? pathname.slice(0, -1)
: pathname;
return PRERENDERED_ROUTES.includes(normalised);
}
/**
* Lazily instantiate AngularNodeAppEngine so the engine boots only when
* the first request arrives. The engine handles both prerendered route
* lookups and on-demand SSR bootstrapping internally.
*/
let appEngine: AngularNodeAppEngine | null = null;
function getEngine(): AngularNodeAppEngine {
if (!appEngine) {
appEngine = new AngularNodeAppEngine();
}
return appEngine;
}
/**
* Handle incoming requests using the Angular SSR engine.
*
* Prerendered routes (/ , /about, /schedule, /events, /contact) are served
* as pre-generated static HTML from the build output. All other routes
* fall back to server-side rendering (SSR) or the fallback 404 handler.
*/
const requestHandler = createNodeRequestHandler(async (req, res, next) => { const requestHandler = createNodeRequestHandler(async (req, res, next) => {
try { try {
const prerendered = isPrerenderedRoute(req.url || '/'); const response = await appEngine.handle(req);
if (prerendered) {
console.log(`[SSR] Prerendered route: ${req.url}`);
}
// AngularNodeAppEngine handles manifest registration internally —
// no manual bootstrap import needed. For prerendered routes it
// returns pre-generated static HTML; for SSR routes it bootstraps
// the Angular app on demand.
const response = await getEngine().handle(req);
if (response) { if (response) {
await writeResponseToNodeResponse(response, res); await writeResponseToNodeResponse(response, res);
@@ -81,17 +34,24 @@ const requestHandler = createNodeRequestHandler(async (req, res, next) => {
} }
}); });
export default requestHandler;
if (isMainModule(import.meta.url)) { if (isMainModule(import.meta.url)) {
const httpServer = http.createServer(); console.log('Node.js SSR server ready');
httpServer.on('request', async (req: http.IncomingMessage, res: http.ServerResponse) => { const server = http.createServer();
requestHandler(req, res, () => { server.on('request', async (req, res) => {
try {
await requestHandler(req, res, () => {
res.statusCode = 404; res.statusCode = 404;
res.end('Not Found'); res.end('Not Found');
}); });
} catch (error) {
console.error('SSR error:', error);
res.statusCode = 500;
res.end('Internal server error');
}
}); });
httpServer.listen(4200, () => { server.listen(4200, () => {
console.log(`Node SSR server listening on http://localhost:4200`); console.log('SSR server listening on http://localhost:4200');
}); });
} }
export { requestHandler };
+14 -14
View File
@@ -10,34 +10,34 @@ describe('SSR server routes — app.server.routes.ts', () => {
expect(serverRoutes).toHaveLength(8); expect(serverRoutes).toHaveLength(8);
}); });
it('should prerender the home route', () => { it('should use SSR mode for the home route', () => {
const home = serverRoutes.find((r) => r.path === ''); const home = serverRoutes.find((r) => r.path === '');
expect(home).toBeDefined(); expect(home).toBeDefined();
expect(home!.renderMode).toBe(RenderMode.Prerender); expect(home!.renderMode).toBe(RenderMode.Server);
}); });
it('should prerender /about', () => { it('should use SSR mode for /about', () => {
const about = serverRoutes.find((r) => r.path === 'about'); const about = serverRoutes.find((r) => r.path === 'about');
expect(about).toBeDefined(); expect(about).toBeDefined();
expect(about!.renderMode).toBe(RenderMode.Prerender); expect(about!.renderMode).toBe(RenderMode.Server);
}); });
it('should prerender /schedule', () => { it('should use SSR mode for /schedule', () => {
const schedule = serverRoutes.find((r) => r.path === 'schedule'); const schedule = serverRoutes.find((r) => r.path === 'schedule');
expect(schedule).toBeDefined(); expect(schedule).toBeDefined();
expect(schedule!.renderMode).toBe(RenderMode.Prerender); expect(schedule!.renderMode).toBe(RenderMode.Server);
}); });
it('should prerender /events', () => { it('should use SSR mode for /events', () => {
const events = serverRoutes.find((r) => r.path === 'events'); const events = serverRoutes.find((r) => r.path === 'events');
expect(events).toBeDefined(); expect(events).toBeDefined();
expect(events!.renderMode).toBe(RenderMode.Prerender); expect(events!.renderMode).toBe(RenderMode.Server);
}); });
it('should prerender /contact', () => { it('should use SSR mode for /contact', () => {
const contact = serverRoutes.find((r) => r.path === 'contact'); const contact = serverRoutes.find((r) => r.path === 'contact');
expect(contact).toBeDefined(); expect(contact).toBeDefined();
expect(contact!.renderMode).toBe(RenderMode.Prerender); expect(contact!.renderMode).toBe(RenderMode.Server);
}); });
it('should use SSR mode for /login', () => { it('should use SSR mode for /login', () => {
@@ -58,13 +58,13 @@ describe('SSR server routes — app.server.routes.ts', () => {
expect(wildcard!.renderMode).toBe(RenderMode.Server); expect(wildcard!.renderMode).toBe(RenderMode.Server);
}); });
it('should have exactly 5 prerendered routes', () => { it('should have no prerendered routes (eliminates stale DEFAULT_CONFIG)', () => {
const prerendered = serverRoutes.filter((r) => r.renderMode === RenderMode.Prerender); const prerendered = serverRoutes.filter((r) => r.renderMode === RenderMode.Prerender);
expect(prerendered).toHaveLength(5); expect(prerendered).toHaveLength(0);
}); });
it('should have exactly 3 SSR routes', () => { it('should have exactly 8 SSR routes (all routes)', () => {
const ssr = serverRoutes.filter((r) => r.renderMode === RenderMode.Server); const ssr = serverRoutes.filter((r) => r.renderMode === RenderMode.Server);
expect(ssr).toHaveLength(3); expect(ssr).toHaveLength(8);
}); });
}); });
+10 -8
View File
@@ -3,16 +3,18 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
/** /**
* Server routes configuration for Angular SSR. * Server routes configuration for Angular SSR.
* *
* The five public routes are prerendered at build time (SSG) for optimal SEO. * ALL routes use RenderMode.Server — every request hits the SSR server,
* Authenticated routes (/login, /admin) use SSR for dynamic rendering. * which fetches fresh station config from the API before rendering HTML.
* All other routes fall back to SSR. * This eliminates the stale-asset flash that occurred with prerendered HTML
* (prerender runs at build time when the API is unavailable, baking in
* DEFAULT_CONFIG values).
*/ */
export const serverRoutes: ServerRoute[] = [ export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender }, { path: '', renderMode: RenderMode.Server },
{ path: 'about', renderMode: RenderMode.Prerender }, { path: 'about', renderMode: RenderMode.Server },
{ path: 'schedule', renderMode: RenderMode.Prerender }, { path: 'schedule', renderMode: RenderMode.Server },
{ path: 'events', renderMode: RenderMode.Prerender }, { path: 'events', renderMode: RenderMode.Server },
{ path: 'contact', renderMode: RenderMode.Prerender }, { path: 'contact', renderMode: RenderMode.Server },
{ path: 'login', renderMode: RenderMode.Server }, { path: 'login', renderMode: RenderMode.Server },
{ path: 'admin', renderMode: RenderMode.Server }, { path: 'admin', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Server }, { path: '**', renderMode: RenderMode.Server },
+7
View File
@@ -4,13 +4,20 @@ import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
import { APP_CONFIG, AppConfig } from './app/services/app-config.service';
import { serverRoutes } from './app/app.server.routes'; import { serverRoutes } from './app/app.server.routes';
// During SSR, the API base URL must be absolute (HttpClient runs in Node.js).
// nginx proxies /api/ to the upstream, so point at nginx (port 80) locally.
const ssrApiBaseUrl = process.env['SSR_API_BASE_URL'] || 'http://localhost:80';
const ssrAppConfig: AppConfig = { apiBaseUrl: ssrApiBaseUrl };
const serverAppConfig = { const serverAppConfig = {
...appConfig, ...appConfig,
providers: [ providers: [
...appConfig.providers, ...appConfig.providers,
{ provide: APP_ID, useValue: 'server' }, { provide: APP_ID, useValue: 'server' },
{ provide: APP_CONFIG, useValue: ssrAppConfig },
provideServerRendering(withRoutes(serverRoutes)), provideServerRendering(withRoutes(serverRoutes)),
], ],
}; };
+1 -1
View File
@@ -3,7 +3,7 @@ export default {
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
include: ['src/**/*.spec.ts'], include: ['src/**/*.spec.ts', 'server.spec.ts'],
setupFiles: ['src/test-setup.ts'], setupFiles: ['src/test-setup.ts'],
reporters: ['default'], reporters: ['default'],
coverage: { coverage: {