From b0696a404d3abf37c35c72898133cb06d6bb87ee Mon Sep 17 00:00:00 2001 From: Tim Donohue Date: Mon, 9 Jan 2023 13:59:02 -0600 Subject: [PATCH] Add SSR caching via lru-cache. Update Cache-Control header to 1 week, but tell browsers not to cache index.html --- config/config.example.yml | 7 + package.json | 1 + server.ts | 241 +++++++++++++++++++++------ src/config/cache-config.interface.ts | 8 + src/config/default-app-config.ts | 8 + src/index.html | 1 + yarn.lock | 2 +- 7 files changed, 217 insertions(+), 51 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index af04859201..28ba095098 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -32,12 +32,19 @@ cache: # NOTE: how long should objects be cached for by default msToLive: default: 900000 # 15 minutes + # Cache-Control HTTP Header control: max-age=60 # revalidate browser autoSync: defaultTime: 0 maxBufferSize: 100 timePerMethod: PATCH: 3 # time in seconds + # In-memory cache of server-side rendered content + serverSide: + # Maximum number of pages (rendered via SSR) to cache. Set to zero to disable server side caching. + max: 100 + # Amount of time after which cached pages are considered stale (in ms) + timeToLive: 900000 # 15 minutes # Authentication settings auth: diff --git a/package.json b/package.json index dcb629a331..945bb1f158 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "jwt-decode": "^3.1.2", "klaro": "^0.7.18", "lodash": "^4.17.21", + "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", "markdown-it-mathjax3": "^4.3.1", "mirador": "^3.3.0", diff --git a/server.ts b/server.ts index ecbbb982d4..c5905e0ccf 100644 --- a/server.ts +++ b/server.ts @@ -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; + // 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)); } /* diff --git a/src/config/cache-config.interface.ts b/src/config/cache-config.interface.ts index c535a96bb5..d0dfc677d9 100644 --- a/src/config/cache-config.interface.ts +++ b/src/config/cache-config.interface.ts @@ -5,6 +5,14 @@ export interface CacheConfig extends Config { msToLive: { default: number; }; + // Cache-Control HTTP Header control: string; autoSync: AutoSyncConfig; + // In-memory cache of server-side rendered content + serverSide: { + // Maximum number of pages (rendered via SSR) to cache. + max: number; + // Amount of time after which cached pages are considered stale (in ms) + timeToLive: number; + } } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 205ea8acc0..516d0eca2e 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -67,11 +67,19 @@ export class DefaultAppConfig implements AppConfig { msToLive: { default: 15 * 60 * 1000 // 15 minutes }, + // Cache-Control HTTP Header control: 'max-age=60', // revalidate browser autoSync: { defaultTime: 0, maxBufferSize: 100, timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds + }, + // In-memory cache of server-side rendered content + serverSide: { + // Maximum number of pages (rendered via SSR) to cache. Set to zero to disable server side caching. + max: 100, + // Amount of time after which cached pages are considered stale (in ms) + timeToLive: 15 * 60 * 1000 // 15 minutes } }; diff --git a/src/index.html b/src/index.html index ddd448f289..565fc0439d 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,7 @@ DSpace + diff --git a/yarn.lock b/yarn.lock index 2bbabcd654..96693299dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7468,7 +7468,7 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.7.1: +lru-cache@^7.14.1, lru-cache@^7.7.1: version "7.14.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea" integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==