mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
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:
241
server.ts
241
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<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));
|
||||
}
|
||||
|
||||
/*
|
||||
|
Reference in New Issue
Block a user