Skip to content
4 changes: 4 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ cache:
# NOTE: When updates are made to compiled *.js files, it will automatically bypass this browser cache, because
# all compiled *.js files include a unique hash in their name which updates when content is modified.
control: max-age=604800 # revalidate browser
# These static files should not be cached (paths relative to dist/browser, including the leading slash)
noCacheFiles:
- '/index.html'
autoSync:
defaultTime: 0
maxBufferSize: 100
Expand Down Expand Up @@ -427,6 +430,7 @@ themes:
# - name: BASE_THEME_NAME
#
- name: dspace
prefetch: true
headTags:
- tagName: link
attributes:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"ngx-pagination": "6.0.3",
"ngx-skeleton-loader": "^9.0.0",
"ngx-ui-switch": "^14.1.0",
"node-html-parser": "^7.0.1",
"nouislider": "^15.7.1",
"pem": "1.14.8",
"reflect-metadata": "^0.2.2",
Expand Down
65 changes: 35 additions & 30 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,47 +18,45 @@
import 'zone.js/node';
import 'reflect-metadata';

/* eslint-disable import/no-namespace */
import * as morgan from 'morgan';
import * as express from 'express';
import * as ejs from 'ejs';
import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip';
import { APP_BASE_HREF } from '@angular/common';
import { enableProdMode } from '@angular/core';
import { CommonEngine } from '@angular/ssr';
/* eslint-enable import/no-namespace */
import axios from 'axios';
import LRU from 'lru-cache';
import { isbot } from 'isbot';
import { createCertificate } from 'pem';
import { createServer } from 'https';
import { json } from 'body-parser';
import { createHttpTerminator } from 'http-terminator';

import * as compression from 'compression';
import * as ejs from 'ejs';
import * as express from 'express';
import * as expressStaticGzip from 'express-static-gzip';
import { readFileSync } from 'fs';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { createHttpTerminator } from 'http-terminator';
import { createServer } from 'https';
import { isbot } from 'isbot';
import LRU from 'lru-cache';
/* eslint-disable import/no-namespace */
import * as morgan from 'morgan';
import { join } from 'path';
import { createCertificate } from 'pem';

import { enableProdMode } from '@angular/core';


import { environment } from './src/environments/environment';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model';
import { hasValue } from './src/app/shared/empty.util';
import { UIServerConfig } from './src/config/ui-server-config.interface';
import bootstrap from './src/main.server';
import { buildAppConfig } from './src/config/config.server';
import {
APP_CONFIG,
AppConfig,
} from './src/config/app-config.interface';
import { buildAppConfig } from './src/config/config.server';
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message';
import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model';
import { CommonEngine } from '@angular/ssr';
import { APP_BASE_HREF } from '@angular/common';
import { SsrExcludePatterns } from './src/config/ssr-config.interface';
import { UIServerConfig } from './src/config/ui-server-config.interface';
import { environment } from './src/environments/environment';
import {
REQUEST,
RESPONSE,
} from './src/express.tokens';
import { SsrExcludePatterns } from "./src/config/ssr-config.interface";
import bootstrap from './src/main.server';
import { ServerHashedFileMapping } from './src/modules/dynamic-hash/hashed-file-mapping.server';
import { logStartupMessage } from './startup-message';

/*
* Set path for the browser application's dist folder
Expand All @@ -71,7 +69,11 @@ const indexHtml = join(DIST_FOLDER, 'index.html');

const cookieParser = require('cookie-parser');

const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
const configJson = join(DIST_FOLDER, 'assets/config.json');
const hashedFileMapping = new ServerHashedFileMapping(DIST_FOLDER, 'index.html');
const appConfig: AppConfig = buildAppConfig(configJson, hashedFileMapping);
appConfig.themes.forEach(themeConfig => hashedFileMapping.addThemeStyle(themeConfig.name, themeConfig.prefetch));
hashedFileMapping.save();

// cache of SSR pages for known bots, only enabled in production mode
let botCache: LRU<string, any>;
Expand Down Expand Up @@ -319,15 +321,14 @@ function clientSideRender(req, res) {
// Replace base href dynamically
html = html.replace(
/<base href="[^"]*">/,
`<base href="${namespace.endsWith('/') ? namespace : namespace + '/'}">`
`<base href="${namespace.endsWith('/') ? namespace : namespace + '/'}">`,
);

// Replace REST URL with UI URL
if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) {
html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl);
}

res.send(html);
res.set('Cache-Control', 'no-cache, no-store').send(html);
}


Expand All @@ -338,7 +339,11 @@ function clientSideRender(req, res) {
*/
function addCacheControl(req, res, next) {
// instruct browser to revalidate
res.header('Cache-Control', environment.cache.control || 'max-age=604800');
if (environment.cache.noCacheFiles.includes(req.originalUrl)) {
res.header('Cache-Control', 'no-cache, no-store');
} else {
res.header('Cache-Control', environment.cache.control || 'max-age=604800');
}
next();
}

Expand Down
6 changes: 6 additions & 0 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
} from '../config/app-config.interface';
import { StoreDevModules } from '../config/store/devtools';
import { environment } from '../environments/environment';
import { HashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping';
import { BrowserHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.browser';
import { EagerThemesModule } from '../themes/eager-themes.module';
import { appEffects } from './app.effects';
import {
Expand Down Expand Up @@ -154,6 +156,10 @@ export const commonAppConfig: ApplicationConfig = {
useClass: DspaceRestInterceptor,
multi: true,
},
{
provide: HashedFileMapping,
useClass: BrowserHashedFileMapping,
},
// register the dynamic matcher used by form. MUST be provided by the app module
...DYNAMIC_MATCHER_PROVIDERS,
provideCore(),
Expand Down
10 changes: 9 additions & 1 deletion src/app/shared/theme-support/theme.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Inject,
Injectable,
Injector,
Optional,
} from '@angular/core';
import {
ActivatedRouteSnapshot,
Expand Down Expand Up @@ -39,6 +40,7 @@ import {
ThemeConfig,
} from '../../../config/theme.config';
import { environment } from '../../../environments/environment';
import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping';
import { LinkService } from '../../core/cache/builders/link.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { RemoteData } from '../../core/data/remote-data';
Expand Down Expand Up @@ -71,6 +73,7 @@ import {
} from './theme.model';
import { ThemeState } from './theme.reducer';


export const themeStateSelector = createFeatureSelector<ThemeState>('theme');

export const currentThemeSelector = createSelector(
Expand Down Expand Up @@ -103,6 +106,7 @@ export class ThemeService {
@Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig,
private router: Router,
@Inject(DOCUMENT) private document: any,
@Optional() private hashedFileMapping: HashedFileMapping,
) {
// Create objects from the theme configs in the environment file
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig, injector));
Expand Down Expand Up @@ -225,10 +229,14 @@ export class ThemeService {
// automatically updated if we add nodes later
const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css'));
const link = this.document.createElement('link');
const themeCSS = `${encodeURIComponent(themeName)}-theme.css`;
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', `${encodeURIComponent(themeName)}-theme.css`);
link.setAttribute(
'href',
this.hashedFileMapping?.resolve(themeCSS) ?? themeCSS,
);
// wait for the new css to download before removing the old one to prevent a
// flash of unstyled content
link.onload = () => {
Expand Down
2 changes: 2 additions & 0 deletions src/config/cache-config.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface CacheConfig extends Config {
};
// Cache-Control HTTP Header
control: string;
// These static files should not be cached (paths relative to dist/browser, including the leading slash)
noCacheFiles: string[]
autoSync: AutoSyncConfig;
// In-memory caches of server-side rendered (SSR) content. These caches can be used to limit the frequency
// of re-generating SSR pages to improve performance.
Expand Down
21 changes: 19 additions & 2 deletions src/config/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { load } from 'js-yaml';
import { join } from 'path';

import { isNotEmpty } from '../app/shared/empty.util';
import { ServerHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.server';
import { AppConfig } from './app-config.interface';
import { BuildConfig } from './build-config.interface';
import { Config } from './config.interface';
import { mergeConfig } from './config.util';
import { DefaultAppConfig } from './default-app-config';
Expand Down Expand Up @@ -168,6 +170,7 @@ const buildBaseUrl = (config: ServerConfig): void => {
].join('');
};


/**
* Build app config with the following chain of override.
*
Expand All @@ -178,7 +181,7 @@ const buildBaseUrl = (config: ServerConfig): void => {
* @param destConfigPath optional path to save config file
* @returns app config
*/
export const buildAppConfig = (destConfigPath?: string): AppConfig => {
export const buildAppConfig = (destConfigPath?: string, mapping?: ServerHashedFileMapping): AppConfig => {
// start with default app config
const appConfig: AppConfig = new DefaultAppConfig();

Expand Down Expand Up @@ -246,7 +249,21 @@ export const buildAppConfig = (destConfigPath?: string): AppConfig => {
buildBaseUrl(appConfig.rest);

if (isNotEmpty(destConfigPath)) {
writeFileSync(destConfigPath, JSON.stringify(appConfig, null, 2));
const content = JSON.stringify(appConfig, null, 2);

writeFileSync(destConfigPath, content);
if (mapping !== undefined) {
mapping.add(destConfigPath, content);
if (!(appConfig as BuildConfig).ssr?.enabled) {
// If we're serving for CSR we can retrieve the configuration before JS is loaded/executed
mapping.addHeadLink({
path: destConfigPath,
rel: 'preload',
as: 'fetch',
crossorigin: 'anonymous',
});
}
}

console.log(`Angular ${bold('config.json')} file generated correctly at ${bold(destConfigPath)} \n`);
}
Expand Down
4 changes: 4 additions & 0 deletions src/config/default-app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export class DefaultAppConfig implements AppConfig {
},
// Cache-Control HTTP Header
control: 'max-age=604800', // revalidate browser
// These static files should not be cached (paths relative to dist/browser, including the leading slash)
noCacheFiles: [
'/index.html', // see https://web.dev/articles/http-cache#unversioned-urls
],
autoSync: {
defaultTime: 0,
maxBufferSize: 100,
Expand Down
5 changes: 5 additions & 0 deletions src/config/theme.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export interface NamedThemeConfig extends Config {
* A list of HTML tags that should be added to the HEAD section of the document, whenever this theme is active.
*/
headTags?: HeadTagConfig[];

/**
* Whether this theme's CSS should be prefetched in CSR mode
*/
prefetch?: boolean;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/environments/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export const environment: BuildConfig = {
},
// msToLive: 1000, // 15 minutes
control: 'max-age=60',
// These static files should not be cached (paths relative to dist/browser, including the leading slash)
noCacheFiles: [
'/index.html', // see https://web.dev/articles/http-cache#unversioned-urls
],
autoSync: {
defaultTime: 0,
maxBufferSize: 100,
Expand Down
4 changes: 3 additions & 1 deletion src/main.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { AppConfig } from './config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './config/config.util';
import { environment } from './environments/environment';
import { browserAppConfig } from './modules/app/browser-app.config';
import { BrowserHashedFileMapping } from './modules/dynamic-hash/hashed-file-mapping.browser';

const hashedFileMapping = new BrowserHashedFileMapping(document);
/*const bootstrap = () => platformBrowserDynamic()
.bootstrapModule(BrowserAppModule, {});*/
const bootstrap = () => bootstrapApplication(AppComponent, browserAppConfig);
Expand All @@ -33,7 +35,7 @@ const main = () => {
return bootstrap();
} else {
// Configuration must be fetched explicitly
return fetch('assets/config.json')
return fetch(hashedFileMapping.resolve('assets/config.json'))
.then((response) => response.json())
.then((config: AppConfig) => {
// extend environment with app config for browser when not prerendered
Expand Down
46 changes: 46 additions & 0 deletions src/modules/dynamic-hash/hashed-file-mapping.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { DOCUMENT } from '@angular/common';
import {
Inject,
Injectable,
Optional,
} from '@angular/core';
import isObject from 'lodash/isObject';
import isString from 'lodash/isString';

import { hasValue } from '../../app/shared/empty.util';
import {
HashedFileMapping,
ID,
} from './hashed-file-mapping';

/**
* Client-side implementation of {@link HashedFileMapping}.
* Reads out the mapping from index.html before the app is bootstrapped.
* Afterwards, {@link resolve} can be used to grab the latest file.
*/
@Injectable()
export class BrowserHashedFileMapping extends HashedFileMapping {
constructor(
@Optional() @Inject(DOCUMENT) protected document: any,
) {
super();
const element = document?.querySelector(`script#${ID}`);

if (hasValue(element?.textContent)) {
const mapping = JSON.parse(element.textContent);

if (isObject(mapping)) {
Object.entries(mapping)
.filter(([key, value]) => isString(key) && isString(value))
.forEach(([plainPath, hashPath]) => this.map.set(plainPath, hashPath));
}
}
}
}
Loading
Loading