mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-14 21:43:04 +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:
@@ -32,12 +32,19 @@ cache:
|
|||||||
# NOTE: how long should objects be cached for by default
|
# NOTE: how long should objects be cached for by default
|
||||||
msToLive:
|
msToLive:
|
||||||
default: 900000 # 15 minutes
|
default: 900000 # 15 minutes
|
||||||
|
# Cache-Control HTTP Header
|
||||||
control: max-age=60 # revalidate browser
|
control: max-age=60 # revalidate browser
|
||||||
autoSync:
|
autoSync:
|
||||||
defaultTime: 0
|
defaultTime: 0
|
||||||
maxBufferSize: 100
|
maxBufferSize: 100
|
||||||
timePerMethod:
|
timePerMethod:
|
||||||
PATCH: 3 # time in seconds
|
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
|
# Authentication settings
|
||||||
auth:
|
auth:
|
||||||
|
@@ -106,6 +106,7 @@
|
|||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"klaro": "^0.7.18",
|
"klaro": "^0.7.18",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"lru-cache": "^7.14.1",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"markdown-it-mathjax3": "^4.3.1",
|
"markdown-it-mathjax3": "^4.3.1",
|
||||||
"mirador": "^3.3.0",
|
"mirador": "^3.3.0",
|
||||||
|
183
server.ts
183
server.ts
@@ -28,6 +28,7 @@ import * as expressStaticGzip from 'express-static-gzip';
|
|||||||
/* eslint-enable import/no-namespace */
|
/* eslint-enable import/no-namespace */
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import LRU from 'lru-cache';
|
||||||
import { createCertificate } from 'pem';
|
import { createCertificate } from 'pem';
|
||||||
import { createServer } from 'https';
|
import { createServer } from 'https';
|
||||||
import { json } from 'body-parser';
|
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 { extendEnvironmentWithAppConfig } from './src/config/config.util';
|
||||||
import { logStartupMessage } from './startup-message';
|
import { logStartupMessage } from './startup-message';
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set path for the browser application's dist folder
|
* 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'));
|
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
|
// extend environment with app config for server
|
||||||
extendEnvironmentWithAppConfig(environment, appConfig);
|
extendEnvironmentWithAppConfig(environment, appConfig);
|
||||||
|
|
||||||
@@ -87,10 +92,12 @@ export function app() {
|
|||||||
/*
|
/*
|
||||||
* If production mode is enabled in the environment file:
|
* If production mode is enabled in the environment file:
|
||||||
* - Enable Angular's production mode
|
* - 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)
|
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
|
||||||
*/
|
*/
|
||||||
if (environment.production) {
|
if (environment.production) {
|
||||||
enableProdMode();
|
enableProdMode();
|
||||||
|
enableCache();
|
||||||
server.use(compression({
|
server.use(compression({
|
||||||
// only compress responses we've marked as SSR
|
// only compress responses we've marked as SSR
|
||||||
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
|
// 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, …)
|
* Serve static resources (images, i18n messages, …)
|
||||||
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
|
* 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,
|
index: false,
|
||||||
enableBrotli: true,
|
enableBrotli: true,
|
||||||
orderPreference: ['br', 'gzip'],
|
orderPreference: ['br', 'gzip'],
|
||||||
@@ -202,8 +209,11 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
server.get('/app/health', healthCheck);
|
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);
|
server.use(environment.ui.nameSpace, router);
|
||||||
|
|
||||||
@@ -215,6 +225,25 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
function ngApp(req, res) {
|
function ngApp(req, res) {
|
||||||
if (environment.universal.preboot) {
|
if (environment.universal.preboot) {
|
||||||
|
// 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 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, {
|
res.render(indexHtml, {
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
@@ -227,30 +256,37 @@ function ngApp(req, res) {
|
|||||||
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
|
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
|
||||||
}, (err, data) => {
|
}, (err, data) => {
|
||||||
if (hasNoValue(err) && hasValue(data)) {
|
if (hasNoValue(err) && hasValue(data)) {
|
||||||
res.locals.ssr = true; // mark response as SSR
|
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);
|
res.send(data);
|
||||||
|
}
|
||||||
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
|
} 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
|
// 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
|
// sent. These errors occur for various reasons in universal, not all of which are in our
|
||||||
// control to solve.
|
// control to solve.
|
||||||
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
||||||
} else {
|
} else {
|
||||||
console.warn('Error in SSR, serving for direct CSR.');
|
console.warn('Error in server-side rendering (SSR)');
|
||||||
if (hasValue(err)) {
|
if (hasValue(err)) {
|
||||||
console.warn('Error details : ', err);
|
console.warn('Error details : ', err);
|
||||||
}
|
}
|
||||||
res.render(indexHtml, {
|
if (sendToUser) {
|
||||||
req,
|
console.warn('Falling back to serving direct client-side rendering (CSR).');
|
||||||
providers: [{
|
clientSideRender(req, res);
|
||||||
provide: APP_BASE_HREF,
|
}
|
||||||
useValue: req.baseUrl
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
// If preboot is disabled, just serve the client
|
|
||||||
console.log('Universal off, serving for direct CSR');
|
/**
|
||||||
|
* 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, {
|
res.render(indexHtml, {
|
||||||
req,
|
req,
|
||||||
providers: [{
|
providers: [{
|
||||||
@@ -258,17 +294,122 @@ function ngApp(req, res) {
|
|||||||
useValue: req.baseUrl
|
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
|
* Return whether server side caching is enabled in configuration.
|
||||||
* The cache control value can be configured in the environments file and defaults to max-age=60
|
|
||||||
*/
|
*/
|
||||||
function cacheControl(req, res, next) {
|
function cacheEnabled(): boolean {
|
||||||
// instruct browser to revalidate
|
// Caching is only enabled is SSR is enabled AND
|
||||||
res.header('Cache-Control', environment.cache.control || 'max-age=60');
|
// "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();
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@@ -5,6 +5,14 @@ export interface CacheConfig extends Config {
|
|||||||
msToLive: {
|
msToLive: {
|
||||||
default: number;
|
default: number;
|
||||||
};
|
};
|
||||||
|
// Cache-Control HTTP Header
|
||||||
control: string;
|
control: string;
|
||||||
autoSync: AutoSyncConfig;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -67,11 +67,19 @@ export class DefaultAppConfig implements AppConfig {
|
|||||||
msToLive: {
|
msToLive: {
|
||||||
default: 15 * 60 * 1000 // 15 minutes
|
default: 15 * 60 * 1000 // 15 minutes
|
||||||
},
|
},
|
||||||
|
// Cache-Control HTTP Header
|
||||||
control: 'max-age=60', // revalidate browser
|
control: 'max-age=60', // revalidate browser
|
||||||
autoSync: {
|
autoSync: {
|
||||||
defaultTime: 0,
|
defaultTime: 0,
|
||||||
maxBufferSize: 100,
|
maxBufferSize: 100,
|
||||||
timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds
|
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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -6,6 +6,7 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<title>DSpace</title>
|
<title>DSpace</title>
|
||||||
<meta name="viewport" content="width=device-width,minimum-scale=1">
|
<meta name="viewport" content="width=device-width,minimum-scale=1">
|
||||||
|
<meta http-equiv="cache-control" content="no-store">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
@@ -7468,7 +7468,7 @@ lru-cache@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
lru-cache@^7.7.1:
|
lru-cache@^7.14.1, lru-cache@^7.7.1:
|
||||||
version "7.14.1"
|
version "7.14.1"
|
||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea"
|
||||||
integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==
|
integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==
|
||||||
|
Reference in New Issue
Block a user