fix: ensure SSR always fetches fresh assets from database #9
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
|
||||||
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
|
||||||
|
|||||||
+25
-1
@@ -1,11 +1,35 @@
|
|||||||
#!/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
|
||||||
|
|
||||||
|
# Render nginx config from template
|
||||||
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf
|
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.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;'
|
||||||
+28
-7
@@ -13,18 +13,24 @@ log_format kmtn_json escape=json
|
|||||||
|
|
||||||
access_log /logs/access-$log_date.log kmtn_json;
|
access_log /logs/access-$log_date.log kmtn_json;
|
||||||
|
|
||||||
|
upstream ssr_backend {
|
||||||
|
server 127.0.0.1:4200;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
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 +59,27 @@ 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
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4,44 +4,12 @@ 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).
|
|
||||||
* 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`.
|
|
||||||
*/
|
|
||||||
export const PRERENDERED_ROUTES: readonly string[] = [
|
|
||||||
'/',
|
|
||||||
'/about',
|
|
||||||
'/schedule',
|
|
||||||
'/events',
|
|
||||||
'/contact',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* Lazily instantiate AngularNodeAppEngine so the engine boots only when
|
||||||
* the first request arrives. The engine handles both prerendered route
|
* the first request arrives. All routes use RenderMode.Server — every
|
||||||
* lookups and on-demand SSR bootstrapping internally.
|
* request triggers on-demand SSR that fetches fresh API data before rendering.
|
||||||
*/
|
*/
|
||||||
let appEngine: AngularNodeAppEngine | null = null;
|
let appEngine: AngularNodeAppEngine | null = null;
|
||||||
function getEngine(): AngularNodeAppEngine {
|
function getEngine(): AngularNodeAppEngine {
|
||||||
@@ -53,22 +21,10 @@ function getEngine(): AngularNodeAppEngine {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming requests using the Angular SSR engine.
|
* Handle incoming requests using the Angular SSR engine.
|
||||||
*
|
* Every request is rendered on the server with fresh data from the API.
|
||||||
* 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 || '/');
|
|
||||||
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 getEngine().handle(req);
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
@@ -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