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
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:
commit
5b1006d321
9 files changed
+294
-180
No files matched your search
+3
-1
@@ -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
@@ -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;'
|
||||||
+52
-19
@@ -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;
|
||||||
'"time":"$time_iso8601",'
|
default_type application/octet-stream;
|
||||||
'"remote_addr":"$remote_addr",'
|
|
||||||
'"x_forwarded_for":"$http_x_forwarded_for"'
|
|
||||||
'}';
|
|
||||||
|
|
||||||
access_log /logs/access-$log_date.log kmtn_json;
|
log_format kmtn_json escape=json
|
||||||
|
'{'
|
||||||
|
'"time":"$time_iso8601",'
|
||||||
|
'"remote_addr":"$remote_addr",'
|
||||||
|
'"x_forwarded_for":"$http_x_forwarded_for"'
|
||||||
|
'}';
|
||||||
|
|
||||||
server {
|
access_log /logs/access.log kmtn_json;
|
||||||
|
sendfile on;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+152
-67
@@ -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', () => {
|
|
||||||
expect(PRERENDERED_ROUTES).toContain('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isPrerenderedRoute()', () => {
|
it('should not export PRERENDERED_ROUTES (all routes now use RenderMode.Server)', async () => {
|
||||||
it('should match exact prerendered paths', () => {
|
const mod = await import('./server');
|
||||||
expect(isPrerenderedRoute('/')).toBe(true);
|
// The server.ts file no longer exports PRERENDERED_ROUTES or isPrerenderedRoute
|
||||||
expect(isPrerenderedRoute('/about')).toBe(true);
|
// because all routes use on-demand SSR. Verify the file doesn't reference them.
|
||||||
expect(isPrerenderedRoute('/schedule')).toBe(true);
|
const fs = require('fs');
|
||||||
expect(isPrerenderedRoute('/events')).toBe(true);
|
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||||
expect(isPrerenderedRoute('/contact')).toBe(true);
|
expect(content).not.toContain('PRERENDERED_ROUTES');
|
||||||
});
|
expect(content).not.toContain('isPrerenderedRoute');
|
||||||
|
expect(content).not.toContain('AngularAppEngine');
|
||||||
|
});
|
||||||
|
|
||||||
it('should match paths with trailing slashes', () => {
|
it('should have a clean server with lazy engine bootstrapping', async () => {
|
||||||
expect(isPrerenderedRoute('/about/')).toBe(true);
|
const fs = require('fs');
|
||||||
expect(isPrerenderedRoute('/schedule/')).toBe(true);
|
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||||
expect(isPrerenderedRoute('/events/')).toBe(true);
|
expect(content).toContain('getEngine');
|
||||||
expect(isPrerenderedRoute('/contact/')).toBe(true);
|
expect(content).toContain('AngularNodeAppEngine');
|
||||||
});
|
expect(content).toContain('listen(4200');
|
||||||
|
});
|
||||||
it('should match paths with query strings', () => {
|
});
|
||||||
expect(isPrerenderedRoute('/about?foo=bar')).toBe(true);
|
|
||||||
expect(isPrerenderedRoute('/schedule?day=monday')).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');
|
||||||
it('should match paths with fragments', () => {
|
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
|
||||||
expect(isPrerenderedRoute('/events#upcoming')).toBe(true);
|
|
||||||
});
|
// Every route should use RenderMode.Server
|
||||||
|
const prerenderMatches = content.match(/RenderMode\.Prerender/g);
|
||||||
it('should reject non-prerendered routes', () => {
|
expect(prerenderMatches).toBeNull();
|
||||||
expect(isPrerenderedRoute('/login')).toBe(false);
|
|
||||||
expect(isPrerenderedRoute('/admin')).toBe(false);
|
// Should have RenderMode.Server entries
|
||||||
expect(isPrerenderedRoute('/nonexistent')).toBe(false);
|
const serverMatches = content.match(/RenderMode\.Server/g);
|
||||||
expect(isPrerenderedRoute('/admin/dashboard')).toBe(false);
|
expect(serverMatches).not.toBeNull();
|
||||||
});
|
expect(serverMatches.length).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
it('should handle full URLs', () => {
|
|
||||||
expect(isPrerenderedRoute('https://kmtnflower.org/about')).toBe(true);
|
it('should have no Prerender routes (eliminates stale baked-in DEFAULT_CONFIG)', async () => {
|
||||||
expect(isPrerenderedRoute('http://localhost:4200/events')).toBe(true);
|
const fs = require('fs');
|
||||||
expect(isPrerenderedRoute('https://kmtnflower.org/login')).toBe(false);
|
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
|
||||||
});
|
expect(content).not.toContain('RenderMode.Prerender');
|
||||||
|
});
|
||||||
it('should normalise root path — "/" stays "/"', () => {
|
});
|
||||||
expect(isPrerenderedRoute('/')).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 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 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 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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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) => {
|
||||||
res.statusCode = 404;
|
try {
|
||||||
res.end('Not Found');
|
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, () => {
|
server.listen(4200, () => {
|
||||||
console.log(`Node SSR server listening on http://localhost:4200`);
|
console.log('SSR server listening on http://localhost:4200');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { requestHandler };
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
Reference in new issue
Block a user