Add SSR caching via lru-cache. Update Cache-Control header to 1 week, but tell browsers not to cache index.html

This commit is contained in:
Tim Donohue
2023-01-09 13:59:02 -06:00
parent 485bb840ce
commit b0696a404d
7 changed files with 217 additions and 51 deletions

241
server.ts
View File

@@ -28,6 +28,7 @@ import * as expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */
import axios from 'axios';
import LRU from 'lru-cache';
import { createCertificate } from 'pem';
import { createServer } from 'https';
import { json } from 'body-parser';
@@ -54,6 +55,7 @@ import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message';
/*
* Set path for the browser application's dist folder
*/
@@ -67,6 +69,9 @@ const cookieParser = require('cookie-parser');
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
// cache of SSR pages, only enabled in production mode
let cache: LRU<string, any>;
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
@@ -87,10 +92,12 @@ export function app() {
/*
* If production mode is enabled in the environment file:
* - Enable Angular's production mode
* - Enable caching of SSR rendered pages (if enabled in config.yml)
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
*/
if (environment.production) {
enableProdMode();
enableCache();
server.use(compression({
// only compress responses we've marked as SSR
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
@@ -186,7 +193,7 @@ export function app() {
* Serve static resources (images, i18n messages, …)
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
*/
router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, {
router.get('*.*', addCacheControl, expressStaticGzip(DIST_FOLDER, {
index: false,
enableBrotli: true,
orderPreference: ['br', 'gzip'],
@@ -202,8 +209,11 @@ export function app() {
*/
server.get('/app/health', healthCheck);
// Register the ngApp callback function to handle incoming requests
router.get('*', ngApp);
/**
* Default sending all incoming requests to ngApp() function, after first checking for a cached
* copy of the page (see cacheCheck())
*/
router.get('*', cacheCheck, ngApp);
server.use(environment.ui.nameSpace, router);
@@ -215,60 +225,191 @@ export function app() {
*/
function ngApp(req, res) {
if (environment.universal.preboot) {
res.render(indexHtml, {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
res.locals.ssr = true; // mark response as SSR
res.send(data);
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been
// sent. These errors occur for various reasons in universal, not all of which are in our
// control to solve.
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
} else {
console.warn('Error in SSR, serving for direct CSR.');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
res.render(indexHtml, {
req,
providers: [{
provide: APP_BASE_HREF,
useValue: req.baseUrl
}]
});
}
});
// Render the page to user via SSR (server side rendering)
serverSideRender(req, res);
} else {
// If preboot is disabled, just serve the client
console.log('Universal off, serving for direct CSR');
res.render(indexHtml, {
req,
providers: [{
provide: APP_BASE_HREF,
useValue: req.baseUrl
}]
console.log('Universal off, serving for direct client-side rendering (CSR)');
clientSideRender(req, res);
}
}
/**
* Render page content on server side using Angular SSR. By default this page content is
* returned to the user.
* @param req current request
* @param res current response
* @param sendToUser if true (default), send the rendered content to the user.
* If false, then only save this rendered content to the in-memory cache (to refresh cache).
*/
function serverSideRender(req, res, sendToUser: boolean = true) {
// Render the page via SSR (server side rendering)
res.render(indexHtml, {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
res.locals.ssr = true; // mark response as SSR (enables text compression)
// save server side rendered data to cache
saveToCache(getCacheKey(req), data);
if (sendToUser) {
// send rendered page to user
res.send(data);
}
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been
// sent. These errors occur for various reasons in universal, not all of which are in our
// control to solve.
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
} else {
console.warn('Error in server-side rendering (SSR)');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
if (sendToUser) {
console.warn('Falling back to serving direct client-side rendering (CSR).');
clientSideRender(req, res);
}
}
});
}
/**
* Send back response to user to trigger direct client-side rendering (CSR)
* @param req current request
* @param res current response
*/
function clientSideRender(req, res) {
res.render(indexHtml, {
req,
providers: [{
provide: APP_BASE_HREF,
useValue: req.baseUrl
}]
});
}
/*
* Adds a Cache-Control HTTP header to the response.
* The cache control value can be configured in the config.*.yml file
* Defaults to max-age=604,800 seconds (1 week)
*/
function addCacheControl(req, res, next) {
// instruct browser to revalidate
res.header('Cache-Control', environment.cache.control || 'max-age=604800');
next();
}
/*
* Enable server-side caching of pages rendered via SSR.
*/
function enableCache() {
if (cacheEnabled()) {
// Initialize a new "least-recently-used" item cache.
// See https://www.npmjs.com/package/lru-cache
cache = new LRU( {
max: environment.cache.serverSide.max || 100, // 100 items in cache maximum
ttl: environment.cache.serverSide.timeToLive || 15 * 60 * 1000, // 15 minute cache
allowStale: true // If object is found to be stale, return stale value before deleting
});
}
}
/*
* Adds a cache control header to the response
* The cache control value can be configured in the environments file and defaults to max-age=60
/**
* Return whether server side caching is enabled in configuration.
*/
function cacheControl(req, res, next) {
// instruct browser to revalidate
res.header('Cache-Control', environment.cache.control || 'max-age=60');
next();
function cacheEnabled(): boolean {
// Caching is only enabled is SSR is enabled AND
// "serverSide.max" setting is greater than zero
return environment.universal.preboot && environment.cache.serverSide.max && (environment.cache.serverSide.max > 0);
}
/**
* Check if the currently requested page is in our server-side, in-memory cache.
* Caching is ONLY done for SSR requests. Pages are cached base on their path (e.g. /home or /search?query=test)
*/
function cacheCheck(req, res, next) {
let cacheHit = false;
let debug = false; // Enable to see cache hits & re-rendering logs
// Only check cache if cache enabled & SSR is enabled
if (cacheEnabled()) {
const key = getCacheKey(req);
// Check if this page is in our cache
let cachedCopy = cache.get(key);
if (cachedCopy) {
cacheHit = true;
res.locals.ssr = true; // mark response as SSR (enables text compression)
if (debug) { console.log(`CACHE HIT FOR ${key}`); }
// return page from cache to user
res.send(cachedCopy);
// Check if cached copy is expired (in this sitution key will now be gone from cache)
if (!cache.has(key)) {
if (debug) { console.log(`CACHE EXPIRED FOR ${key} Re-rendering...`); }
// Update cached copy by rerendering server-side
// NOTE: Cached copy was already returned to user above. So, this re-render is just to prepare for next user.
serverSideRender(req, res, false);
}
// Tell Express to skip all other handlers for this path
// This ensures we don't try to re-render the page since we've already returned the cached copy
next('router');
}
}
// If nothing found in cache, just continue with next handler
// (This should send the request on to the handler that rerenders the page via SSR)
if (!cacheHit) {
next();
}
}
/**
* Create a cache key from the current request.
* The cache key is the URL path (NOTE: this key will also include any querystring params).
* E.g. "/home" or "/search?query=test"
* @param req current request
* @returns cache key to use for this page
*/
function getCacheKey(req): string {
// NOTE: this will return the URL path *without* any baseUrl
return req.url;
}
/**
* Save data to server side cache, if enabled. If caching is not enabled, this is a noop
* @param key page key
* @param data page data to save to cache
*/
function saveToCache(key: string, data: any) {
// Only cache if caching is enabled and this path is allowed to be cached
if (cacheEnabled() && canCachePage(key)) {
cache.set(key, data);
}
}
/**
* Whether this path is allowed to be cached. Only public paths can be cached as the cache is shared across all users.
* @param key page key (corresponds to path of page)
* @returns true if allowed to be cached, false otherwise.
*/
function canCachePage(key: string) {
// Only these publicly accessible pages can be cached.
// NOTE: Caching pages which require authentication is NOT ALLOWED. The same cache is used by all users & user-specific data must NEVER appear in cache.
const allowedPages = [/^\/$/, /^\/home$/, /^\/items\//, /^\/entities\//, /^\/collections\//, /^\/communities\//, /^\/search[\/?]?/, /\/browse\//, /^\/community-list$/];
// Check whether any of these regexs match with the passed in key
return allowedPages.some(regex => regex.test(key));
}
/*