feat(ssr): spider-only SSR serve CSR shell for regular users #11
No files matched your search
+6
-3
@@ -78,9 +78,8 @@ http {
|
||||
}
|
||||
|
||||
# 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.
|
||||
# The SSR server handles both spider SSR rendering and serves CSR shells
|
||||
# to regular users, with fresh station config injected at runtime.
|
||||
location / {
|
||||
proxy_pass http://ssr_backend;
|
||||
proxy_set_header Host $host;
|
||||
@@ -92,6 +91,10 @@ http {
|
||||
# SSR responses should not be cached by intermediate proxies
|
||||
proxy_cache off;
|
||||
add_header X-Powered-By "Angular SSR" always;
|
||||
|
||||
# Prevent browsers from caching HTML pages — content changes frequently
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-4
@@ -28,7 +28,9 @@ vi.mock('node:path', () => ({
|
||||
}));
|
||||
|
||||
// Mock the engine manifest that only exists after ng build
|
||||
vi.mock('./angular-app-engine-manifest.mjs', () => ({}));
|
||||
vi.mock('./angular-app-engine-manifest.mjs', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
describe('SSR server — server.ts', () => {
|
||||
it('should export a request handler', async () => {
|
||||
@@ -36,12 +38,12 @@ describe('SSR server — server.ts', () => {
|
||||
expect(mod.requestHandler).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have spider detection imports and serveStaticShell', async () => {
|
||||
it('should have spider detection imports and serveCsrShell', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('isSpider');
|
||||
expect(content).toContain('getUserAgent');
|
||||
expect(content).toContain('serveStaticShell');
|
||||
expect(content).toContain('serveCsrShell');
|
||||
expect(content).toContain('spider_detection');
|
||||
});
|
||||
|
||||
@@ -50,7 +52,45 @@ describe('SSR server — server.ts', () => {
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
// Verify the spider detection gate exists
|
||||
expect(content).toContain("if (!isSpider(userAgent))");
|
||||
expect(content).toContain('serveStaticShell(res)');
|
||||
expect(content).toContain('serveCsrShell(res)');
|
||||
});
|
||||
|
||||
it('should inject fresh station config as window.__STATION_CONFIG', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('window.__STATION_CONFIG');
|
||||
expect(content).toContain('buildConfigScript');
|
||||
expect(content).toContain('fetchStationConfig');
|
||||
});
|
||||
|
||||
it('should inject inline theme CSS custom properties', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('buildThemeStyle');
|
||||
expect(content).toContain('COLOR_MAP');
|
||||
expect(content).toContain('--color-primary');
|
||||
expect(content).toContain(':root{');
|
||||
});
|
||||
|
||||
it('should serveCsrShell be async and accept (res)', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toMatch(/async function serveCsrShell\s*\(\s*res/);
|
||||
});
|
||||
|
||||
it('should fetch station config from SSR_API_BASE_URL with 2s timeout', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('SSR_API_BASE_URL');
|
||||
expect(content).toContain('api/station-config');
|
||||
expect(content).toContain('setTimeout(2000');
|
||||
});
|
||||
|
||||
it('should set no-cache headers on CSR shell responses', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain("Cache-Control");
|
||||
expect(content).toContain("no-cache, no-store, must-revalidate");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,6 +314,13 @@ describe('nginx.conf.template — SSR proxy chain', () => {
|
||||
expect(content).toContain('location /api/');
|
||||
expect(content).toContain('__API_UPSTREAM__');
|
||||
});
|
||||
|
||||
it('should add Cache-Control no-cache headers on HTML page responses', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('add_header Cache-Control "no-cache, no-store, must-revalidate" always');
|
||||
expect(content).toContain('add_header Pragma "no-cache" always');
|
||||
});
|
||||
});
|
||||
|
||||
describe('main.server.ts — SSR API base URL injection', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@angular/ssr/node';
|
||||
import * as fs from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as path from 'node:path';
|
||||
import { isSpider, getUserAgent } from './spider_detection';
|
||||
|
||||
@@ -23,6 +24,53 @@ import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
|
||||
|
||||
const appEngine = new AngularNodeAppEngine();
|
||||
|
||||
/**
|
||||
* Station config shape — mirrors the backend API response.
|
||||
* Only the color fields are used for theme injection; the rest are
|
||||
* passed through verbatim for the client-side StationConfigService.
|
||||
*/
|
||||
interface StationConfig {
|
||||
color_primary: string;
|
||||
color_primary_light: string;
|
||||
color_primary_dark: string;
|
||||
color_primary_muted: string;
|
||||
color_accent: string;
|
||||
color_accent_light: string;
|
||||
color_accent_dark: string;
|
||||
color_accent_muted: string;
|
||||
color_background: string;
|
||||
color_text: string;
|
||||
color_success: string;
|
||||
color_danger: string;
|
||||
color_info: string;
|
||||
color_white: string;
|
||||
color_light: string;
|
||||
color_medium: string;
|
||||
color_black: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** CSS custom property names mapped from StationConfig color fields. */
|
||||
const COLOR_MAP: ReadonlyArray<{ field: keyof StationConfig; prop: string }> = [
|
||||
{ field: 'color_primary', prop: '--color-primary' },
|
||||
{ field: 'color_primary_light', prop: '--color-primary-light' },
|
||||
{ field: 'color_primary_dark', prop: '--color-primary-dark' },
|
||||
{ field: 'color_primary_muted', prop: '--color-primary-muted' },
|
||||
{ field: 'color_accent', prop: '--color-accent' },
|
||||
{ field: 'color_accent_light', prop: '--color-accent-light' },
|
||||
{ field: 'color_accent_dark', prop: '--color-accent-dark' },
|
||||
{ field: 'color_accent_muted', prop: '--color-accent-muted' },
|
||||
{ field: 'color_background', prop: '--color-cream' },
|
||||
{ field: 'color_text', prop: '--color-dark' },
|
||||
{ field: 'color_success', prop: '--color-success' },
|
||||
{ field: 'color_danger', prop: '--color-danger' },
|
||||
{ field: 'color_info', prop: '--color-info' },
|
||||
{ field: 'color_white', prop: '--color-white' },
|
||||
{ field: 'color_light', prop: '--color-light' },
|
||||
{ field: 'color_medium', prop: '--color-medium' },
|
||||
{ field: 'color_black', prop: '--color-black' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the static browser index.html path.
|
||||
*
|
||||
@@ -34,13 +82,16 @@ function browserIndexPath(): string {
|
||||
// Production path — Dockerfile copies browser output here
|
||||
const productionPaths = [
|
||||
'/usr/share/nginx/html/index.html',
|
||||
'/usr/share/nginx/html/index.csr.html',
|
||||
'/var/www/html/index.html',
|
||||
];
|
||||
|
||||
// Local dev path — relative to the compiled server directory
|
||||
const dir = typeof __dirname !== 'undefined' ? __dirname : path.dirname(new URL(import.meta.url).pathname);
|
||||
const localPaths = [
|
||||
path.join(dir, 'browser', 'index.csr.html'),
|
||||
path.join(dir, 'browser', 'index.html'),
|
||||
path.join(dir, '..', 'browser', 'index.csr.html'),
|
||||
path.join(dir, '..', 'browser', 'index.html'),
|
||||
];
|
||||
|
||||
@@ -57,23 +108,110 @@ function browserIndexPath(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the static CSR app shell from disk.
|
||||
*
|
||||
* Regular browser users get the prerendered HTML shell — Angular hydrates
|
||||
* client-side and fetches fresh config via the API. This avoids the SSR
|
||||
* overhead for every human request while still delivering a fast first paint.
|
||||
* Build an inline `<script>` that seeds `window.__STATION_CONFIG` so the
|
||||
* client-side StationConfigService can read fresh config synchronously
|
||||
* during bootstrap — no async API round-trip needed, no DEFAULT_CONFIG flash.
|
||||
*/
|
||||
function serveStaticShell(res: http.ServerResponse): void {
|
||||
const indexPath = browserIndexPath();
|
||||
function buildConfigScript(config: StationConfig): string {
|
||||
const json = JSON.stringify(config);
|
||||
return `<script>window.__STATION_CONFIG=${json};</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inline CSS custom properties on `:root` so the first paint carries
|
||||
* the correct color scheme — eliminates the color flash that occurs when
|
||||
* ThemeService.apply() runs asynchronously after the API call.
|
||||
*/
|
||||
function buildThemeStyle(config: StationConfig): string {
|
||||
const props: string[] = [];
|
||||
|
||||
for (const { field, prop } of COLOR_MAP) {
|
||||
const value = (config as any)[field];
|
||||
if (value && typeof value === 'string') {
|
||||
props.push(`${prop}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.length === 0) return '';
|
||||
|
||||
const css = `:root{${props.join(';')}}`;
|
||||
return `<style>${css}</style>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fresh station config from the backend API.
|
||||
* This runs server-side — it goes through the nginx proxy chain to the
|
||||
* upstream FastAPI backend. On failure we fall back to serving the shell
|
||||
* unmodified (client-side fetch picks up the slack).
|
||||
*/
|
||||
async function fetchStationConfig(): Promise<StationConfig | null> {
|
||||
// SSR_API_BASE_URL points at nginx (localhost:80) which proxies /api/ to
|
||||
// the upstream FastAPI backend. This avoids cross-origin issues and reuses
|
||||
// the same proxy chain the SSR engine itself would use.
|
||||
const baseUrl = process.env['SSR_API_BASE_URL'] || 'http://localhost:80';
|
||||
const url = `${baseUrl}/api/station-config`;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('X-Render-Mode', 'CSR');
|
||||
res.end(content);
|
||||
const client = baseUrl.startsWith('https') ? https : http;
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const req = client.get(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(2000, () => { req.destroy(new Error('Station config fetch timed out')); });
|
||||
});
|
||||
return JSON.parse(data) as StationConfig;
|
||||
} catch (err) {
|
||||
console.warn(`[SSR] Failed to fetch station config from ${url}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the CSR app shell, enhanced with fresh station config injected at
|
||||
* runtime so the client bootstraps with real data — no DEFAULT_CONFIG flash.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Read CSR shell HTML from disk
|
||||
* 2. Fetch live station config from the backend API
|
||||
* 3. Inject window.__STATION_CONFIG as an inline <script> before </head>
|
||||
* 4. Inject theme CSS custom properties as an inline <style> before </head>
|
||||
* 5. The client-side StationConfigService reads window.__STATION_CONFIG
|
||||
* synchronously on construction — it skips the API call when seeded.
|
||||
*/
|
||||
async function serveCsrShell(res: http.ServerResponse): Promise<void> {
|
||||
const indexPath = browserIndexPath();
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(indexPath, 'utf-8');
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
res.end('Internal server error — index.html not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to fetch fresh config to inject into the shell.
|
||||
const config = await fetchStationConfig();
|
||||
|
||||
let enhanced = html;
|
||||
if (config) {
|
||||
const script = buildConfigScript(config);
|
||||
const style = buildThemeStyle(config);
|
||||
const injection = `${style}\n${script}`;
|
||||
enhanced = html.replace('</head>', `${injection}</head>`);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('X-Render-Mode', 'CSR');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.end(enhanced);
|
||||
}
|
||||
|
||||
const requestHandler = createNodeRequestHandler(async (req, res, next) => {
|
||||
@@ -97,11 +235,11 @@ if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
// ── Spider detection gate ──────────────────────────
|
||||
// Only search engine spiders get full SSR rendering.
|
||||
// Regular users receive the static CSR app shell from disk,
|
||||
// which hydrates client-side and fetches fresh data from the API.
|
||||
// Regular users receive the CSR app shell from disk,
|
||||
// enhanced with fresh station config injected at runtime.
|
||||
const userAgent = getUserAgent(req.headers);
|
||||
if (!isSpider(userAgent)) {
|
||||
serveStaticShell(res);
|
||||
await serveCsrShell(res);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,18 @@ export class StationConfigService {
|
||||
private themeService = inject(ThemeService);
|
||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
|
||||
|
||||
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
|
||||
/**
|
||||
* Read pre-seeded config injected by the server at runtime.
|
||||
* The SSR server fetches fresh station config and injects it as
|
||||
* `window.__STATION_CONFIG` before the Angular bundle loads.
|
||||
* This eliminates the DEFAULT_CONFIG flash: the client bootstraps
|
||||
* with real data instead of hardcoded defaults.
|
||||
*/
|
||||
readonly config = signal<StationConfig>(
|
||||
typeof window !== 'undefined' && (window as any).__STATION_CONFIG
|
||||
? (window as any).__STATION_CONFIG as StationConfig
|
||||
: DEFAULT_CONFIG,
|
||||
);
|
||||
|
||||
/** Load station config from the API. Falls back to defaults on error. */
|
||||
async load(): Promise<void> {
|
||||
|
||||
Reference in new issue
Block a user