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
This commit was merged in pull request #9.
This commit is contained in:
kfj001 committed 2026-08-07 16:43:16 -07:00
commit 5b1006d321
9 files changed
+267 -153

No files matched your search

+3 -1
View File
@@ -7,8 +7,10 @@ COPY . .
RUN npx ng build --configuration production
# ── 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/server /app/server
COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template
COPY config.json.template /usr/share/nginx/html/config.json.template
COPY docker-entrypoint.sh /docker-entrypoint.sh
+27 -2
View File
@@ -1,11 +1,36 @@
#!/bin/sh
# Render config templates with environment variables, then start nginx.
# Start the Angular SSR Node process, then nginx.
set -e
# Ensure nginx can write to the shared logs directory
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
# 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;'
+45 -12
View File
@@ -1,9 +1,19 @@
# Map ISO timestamp to date-only string for daily log rotation
map $time_iso8601 $log_date {
"~^(?<date>\d{4}-\d{2}-\d{2})" $date;
default "unknown";
# Full nginx configuration — rendered to /etc/nginx/nginx.conf
# (NOT placed in conf.d/ to avoid include nesting issues with Alpine nginx)
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format kmtn_json escape=json
'{'
'"time":"$time_iso8601",'
@@ -11,7 +21,12 @@ log_format kmtn_json escape=json
'"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;
upstream ssr_backend {
server 127.0.0.1:4200;
}
server {
listen 80;
@@ -19,12 +34,14 @@ server {
root /usr/share/nginx/html;
index index.html;
# SPA fallback — Angular router handles its own paths
location / {
try_files $uri $uri/ /index.html;
# Serve static assets directly from disk (hashed filenames are immutable)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
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.
location = /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-Host $host;
# Disable caching through the double-proxy chain (NPM → frontend nginx → API)
# 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.
# Disable caching through the double-proxy chain
proxy_cache off;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" 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;
}
}
}
+135 -50
View File
@@ -4,7 +4,7 @@ import '@angular/compiler';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 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', () => ({
AngularNodeAppEngine: class {},
createNodeRequestHandler: (fn: Function) => fn,
@@ -16,75 +16,160 @@ vi.mock('node:http', () => ({
default: { createServer: vi.fn() },
}));
import { PRERENDERED_ROUTES, isPrerenderedRoute } from './server';
describe('SSR server — server.ts', () => {
describe('PRERENDERED_ROUTES', () => {
it('should contain exactly 5 routes', () => {
expect(PRERENDERED_ROUTES).toHaveLength(5);
it('should export a default request handler', async () => {
const mod = await import('./server');
expect(mod.default).toBeDefined();
});
it('should include the home route', () => {
expect(PRERENDERED_ROUTES).toContain('/');
it('should not export PRERENDERED_ROUTES (all routes now use RenderMode.Server)', async () => {
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', () => {
expect(PRERENDERED_ROUTES).toContain('/about');
});
it('should include /schedule', () => {
expect(PRERENDERED_ROUTES).toContain('/schedule');
});
it('should include /events', () => {
expect(PRERENDERED_ROUTES).toContain('/events');
});
it('should include /contact', () => {
expect(PRERENDERED_ROUTES).toContain('/contact');
it('should have a clean server with lazy engine bootstrapping', async () => {
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');
});
});
describe('isPrerenderedRoute()', () => {
it('should match exact prerendered paths', () => {
expect(isPrerenderedRoute('/')).toBe(true);
expect(isPrerenderedRoute('/about')).toBe(true);
expect(isPrerenderedRoute('/schedule')).toBe(true);
expect(isPrerenderedRoute('/events')).toBe(true);
expect(isPrerenderedRoute('/contact')).toBe(true);
describe('app.server.routes.ts — RenderMode.Server for all routes', () => {
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 match paths with trailing slashes', () => {
expect(isPrerenderedRoute('/about/')).toBe(true);
expect(isPrerenderedRoute('/schedule/')).toBe(true);
expect(isPrerenderedRoute('/events/')).toBe(true);
expect(isPrerenderedRoute('/contact/')).toBe(true);
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');
});
});
it('should match paths with query strings', () => {
expect(isPrerenderedRoute('/about?foo=bar')).toBe(true);
expect(isPrerenderedRoute('/schedule?day=monday')).toBe(true);
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 match paths with fragments', () => {
expect(isPrerenderedRoute('/events#upcoming')).toBe(true);
it('should start the SSR node process before nginx', () => {
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 reject non-prerendered routes', () => {
expect(isPrerenderedRoute('/login')).toBe(false);
expect(isPrerenderedRoute('/admin')).toBe(false);
expect(isPrerenderedRoute('/nonexistent')).toBe(false);
expect(isPrerenderedRoute('/admin/dashboard')).toBe(false);
it('should set SSR_API_BASE_URL for the SSR server', () => {
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 handle full URLs', () => {
expect(isPrerenderedRoute('https://kmtnflower.org/about')).toBe(true);
expect(isPrerenderedRoute('http://localhost:4200/events')).toBe(true);
expect(isPrerenderedRoute('https://kmtnflower.org/login')).toBe(false);
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');
});
});
it('should normalise root path — "/" stays "/"', () => {
expect(isPrerenderedRoute('/')).toBe(true);
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/);
});
it('should use a Node.js base image for the serve stage', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
// Second FROM should be Node-based (not plain nginx)
const fromLines = content.split('\n').filter(l => l.startsWith('FROM '));
expect(fromLines.length).toBeGreaterThanOrEqual(2);
expect(fromLines[1]).toContain('node');
});
it('should install nginx in the serve stage', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(content).toContain('apk add --no-cache nginx');
});
it('should expose port 80', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(content).toContain('EXPOSE 80');
});
});
describe('nginx.conf.template — SSR proxy chain', () => {
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 proxy page requests to the SSR backend', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('proxy_pass http://ssr_backend');
});
it('should serve static assets directly from disk with long cache', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
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 still proxy /api/ to the upstream backend', () => {
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,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import { AngularAppEngine } from '@angular/ssr';
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).
* These five routes are compiled to static HTML during `ng build` and
* served directly from disk at runtime for optimal performance and SEO.
* The list here mirrors `prerender-routes.txt` and the Prerender entries
* in `src/app/app.server.routes.ts`.
* 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.
*/
export const PRERENDERED_ROUTES: readonly string[] = [
'/',
'/about',
'/schedule',
'/events',
'/contact',
];
// @ts-ignore — generated at build time by @angular/build
import engineManifest from './angular-app-engine-manifest.mjs';
import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
ɵsetAngularAppEngineManifest(engineManifest);
/**
* 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);
}
const appEngine = new AngularNodeAppEngine();
/**
* 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) => {
try {
const prerendered = isPrerenderedRoute(req.url || '/');
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);
const response = await appEngine.handle(req);
if (response) {
await writeResponseToNodeResponse(response, res);
@@ -81,17 +34,24 @@ const requestHandler = createNodeRequestHandler(async (req, res, next) => {
}
});
export default requestHandler;
if (isMainModule(import.meta.url)) {
const httpServer = http.createServer();
httpServer.on('request', async (req: http.IncomingMessage, res: http.ServerResponse) => {
requestHandler(req, res, () => {
console.log('Node.js SSR server ready');
const server = http.createServer();
server.on('request', async (req, res) => {
try {
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');
}
});
httpServer.listen(4200, () => {
console.log(`Node SSR server listening on http://localhost:4200`);
server.listen(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);
});
it('should prerender the home route', () => {
it('should use SSR mode for the home route', () => {
const home = serverRoutes.find((r) => r.path === '');
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');
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');
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');
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');
expect(contact).toBeDefined();
expect(contact!.renderMode).toBe(RenderMode.Prerender);
expect(contact!.renderMode).toBe(RenderMode.Server);
});
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);
});
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);
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);
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.
*
* The five public routes are prerendered at build time (SSG) for optimal SEO.
* Authenticated routes (/login, /admin) use SSR for dynamic rendering.
* All other routes fall back to SSR.
* ALL routes use RenderMode.Server — every request hits the SSR server,
* which fetches fresh station config from the API before rendering HTML.
* 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[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'about', renderMode: RenderMode.Prerender },
{ path: 'schedule', renderMode: RenderMode.Prerender },
{ path: 'events', renderMode: RenderMode.Prerender },
{ path: 'contact', renderMode: RenderMode.Prerender },
{ path: '', renderMode: RenderMode.Server },
{ path: 'about', renderMode: RenderMode.Server },
{ path: 'schedule', renderMode: RenderMode.Server },
{ path: 'events', renderMode: RenderMode.Server },
{ path: 'contact', renderMode: RenderMode.Server },
{ path: 'login', renderMode: RenderMode.Server },
{ path: 'admin', 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 { App } from './app/app';
import { APP_CONFIG, AppConfig } from './app/services/app-config.service';
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 = {
...appConfig,
providers: [
...appConfig.providers,
{ provide: APP_ID, useValue: 'server' },
{ provide: APP_CONFIG, useValue: ssrAppConfig },
provideServerRendering(withRoutes(serverRoutes)),
],
};
+1 -1
View File
@@ -3,7 +3,7 @@ export default {
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
include: ['src/**/*.spec.ts', 'server.spec.ts'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {