diff --git a/config/config.example.yml b/config/config.example.yml index c3dc673b537..8aed35da11c 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -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 @@ -441,6 +444,7 @@ themes: # - name: BASE_THEME_NAME # - name: dspace + prefetch: true headTags: - tagName: link attributes: diff --git a/package-lock.json b/package-lock.json index b694fda607e..231ac629a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^11.3.0", "ngx-ui-switch": "^16.1.0", + "node-html-parser": "^7.0.1", "nouislider": "^15.7.1", "orejime": "^2.3.1", "pem": "1.14.8", @@ -11272,7 +11273,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, "license": "ISC" }, "node_modules/bootstrap": { @@ -13296,7 +13296,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -13311,7 +13310,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, "funding": [ { "type": "github", @@ -13324,7 +13322,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -13345,7 +13342,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -13567,7 +13563,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -15517,6 +15512,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -19650,6 +19654,44 @@ "node": ">=18" } }, + "node_modules/node-html-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", + "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-html-parser/node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/node-html-parser/node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -19954,7 +19996,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" diff --git a/package.json b/package.json index c07a955cbc2..e7b3923e77b 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^11.3.0", "ngx-ui-switch": "^16.1.0", + "node-html-parser": "^7.0.1", "nouislider": "^15.7.1", "orejime": "^2.3.1", "pem": "1.14.8", diff --git a/server.ts b/server.ts index 18426d0ae6f..ba24fa03e87 100644 --- a/server.ts +++ b/server.ts @@ -47,6 +47,7 @@ import { AppConfig, } from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; +import { ServerHashedFileMapping } from './src/modules/dynamic-hash/hashed-file-mapping.server'; import { logStartupMessage } from './startup-message'; import { TOKENITEM } from '@dspace/core/auth/models/auth-token-info.model'; import { CommonEngine } from '@angular/ssr/node'; @@ -68,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: LRUCache; @@ -324,7 +329,7 @@ function clientSideRender(req, res) { 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); } @@ -335,7 +340,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(); } diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 11cd23c57d0..45fa5361377 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -3,6 +3,7 @@ import { Inject, Injectable, Injector, + Optional, } from '@angular/core'; import { ActivatedRouteSnapshot, @@ -62,6 +63,7 @@ import { } from 'rxjs/operators'; import { environment } from '../../../environments/environment'; +import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator'; import { SetThemeAction, @@ -105,6 +107,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, @Inject(APP_CONFIG) private appConfig: BuildConfig, ) { // Create objects from the theme configs in the environment file @@ -228,10 +231,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 = () => { diff --git a/src/config/cache-config.interface.ts b/src/config/cache-config.interface.ts index 9d3fc168cbb..c5609fef8f4 100644 --- a/src/config/cache-config.interface.ts +++ b/src/config/cache-config.interface.ts @@ -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. diff --git a/src/config/config.server.ts b/src/config/config.server.ts index b0a71e96985..4589cffb3c2 100644 --- a/src/config/config.server.ts +++ b/src/config/config.server.ts @@ -14,7 +14,9 @@ import { } from 'colors'; import { load } from 'js-yaml'; +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'; @@ -179,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(); @@ -247,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`); } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 91adee2e0f5..d2fb68440eb 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -81,6 +81,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, diff --git a/src/config/theme.config.ts b/src/config/theme.config.ts index c29566cb990..22a8b8959ef 100644 --- a/src/config/theme.config.ts +++ b/src/config/theme.config.ts @@ -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; } /** diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index bd456f328cd..9a60ad8a10c 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -74,6 +74,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, diff --git a/src/main.browser.ts b/src/main.browser.ts index 8cbce70f523..059b5e8812f 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -10,7 +10,9 @@ import { extendEnvironmentWithAppConfig } from '@dspace/config/config.util'; import { AppComponent } from './app/app.component'; 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); @@ -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 diff --git a/src/modules/dynamic-hash/hashed-file-mapping.browser.ts b/src/modules/dynamic-hash/hashed-file-mapping.browser.ts new file mode 100644 index 00000000000..f118dfee42c --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.browser.ts @@ -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 { hasValue } from '@dspace/shared/utils/empty.util'; +import isObject from 'lodash/isObject'; +import isString from 'lodash/isString'; + +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)); + } + } + } +} diff --git a/src/modules/dynamic-hash/hashed-file-mapping.server.ts b/src/modules/dynamic-hash/hashed-file-mapping.server.ts new file mode 100644 index 00000000000..2559c014c70 --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -0,0 +1,182 @@ +/** + * 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 crypto from 'node:crypto'; +import { + copyFileSync, + existsSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { + extname, + join, + relative, +} from 'node:path'; +import zlib from 'node:zlib'; + +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { globSync } from 'glob'; +import { parse } from 'node-html-parser'; + +import { + HashedFileMapping, + ID, +} from './hashed-file-mapping'; + +const HEAD_LINK_CLASS = 'hfm'; + +interface HeadLink { + path: string; + rel: string; + as: string; + crossorigin?: string; +} + +/** + * Server-side implementation of {@link HashedFileMapping}. + * Registers dynamically hashed files and stores them in index.html for the browser to use. + */ +export class ServerHashedFileMapping extends HashedFileMapping { + public readonly indexPath: string; + private readonly indexContent: string; + + protected readonly headLinks: Map = new Map(); + + constructor( + private readonly root: string, + file: string, + ) { + super(); + this.root = join(root, ''); + this.indexPath = join(root, file); + this.indexContent = readFileSync(this.indexPath).toString(); + } + + /** + * Add a new file to the mapping by an absolute path (within the root directory). + * If {@link content} is provided, the {@link path} itself does not have to exist. + * Otherwise, it is read out from the original path. + * The original path is never overwritten. + */ + add(path: string, content?: string, compress = false): string { + if (content === undefined) { + content = readFileSync(path).toString(); + } + + // remove previous files + const ext = extname(path); + globSync(path.replace(`${ext}`, `.*${ext}*`)) + .forEach(p => rmSync(p)); + + // hash the content + const hash = crypto.createHash('md5') + .update(content) + .digest('hex'); + + // add the hash to the path + const hashPath = path.replace(`${ext}`, `.${hash}${ext}`); + + // store it in the mapping + this.map.set(path, hashPath); + + // write the file + writeFileSync(hashPath, content); + + if (compress) { + // write the file as .br + zlib.brotliCompress(content, (err, compressed) => { + if (err) { + throw new Error('Brotli compress failed'); + } else { + writeFileSync(hashPath + '.br', compressed); + } + }); + + // write the file as .gz + zlib.gzip(content, (err, compressed) => { + if (err) { + throw new Error('Gzip compress failed'); + } else { + writeFileSync(hashPath + '.gz', compressed); + } + }); + } + + return hashPath; + } + + addThemeStyle(theme: string, prefetch = true) { + const path = `${this.root}/${theme}-theme.css`; + const hashPath = this.add(path); + + if (prefetch) { + // We know this CSS is likely needed, so we can avoid a FOUC by retrieving it in advance + // Angular does the same for global styles, but doesn't "know" about our themes + this.addHeadLink({ + path, + rel: 'prefetch', + as: 'style', + }); + } + + // We know theme CSS has been compressed already + this.ensureCompressedFilesAssumingUnchangedContent(path, hashPath, '.br'); + this.ensureCompressedFilesAssumingUnchangedContent(path, hashPath, '.gz'); + } + + /** + * Include a head link for a given resource to the index HTML. + */ + addHeadLink(headLink: HeadLink) { + this.headLinks.set(headLink.path, headLink); + } + + private renderHeadLink(link: HeadLink): string { + const href = relative(this.root, this.resolve(link.path)); + + if (hasValue(link.crossorigin)) { + return ``; + } else { + return ``; + } + } + + private ensureCompressedFilesAssumingUnchangedContent(path: string, hashedPath: string, compression: string) { + const compressedPath = `${path}${compression}`; + const compressedHashedPath = `${hashedPath}${compression}`; + + if (existsSync(compressedPath) && !existsSync(compressedHashedPath)) { + copyFileSync(compressedPath, compressedHashedPath); + } + } + + /** + * Save the mapping as JSON in the index file. + * The updated index file itself is hashed as well, and must be sent {@link resolve}. + */ + save(): void { + const out = Array.from(this.map.entries()) + .reduce((object, [plain, hashed]) => { + object[relative(this.root, plain)] = relative(this.root, hashed); + return object; + }, {}); + + const root = parse(this.indexContent); + root.querySelectorAll(`script#${ID}, link.${HEAD_LINK_CLASS}`)?.forEach(e => e.remove()); + root.querySelector('head') + .appendChild(`` as any); + + for (const headLink of this.headLinks.values()) { + root.querySelector('head') + .appendChild(this.renderHeadLink(headLink) as any); + } + + writeFileSync(this.indexPath, root.toString()); + } +} diff --git a/src/modules/dynamic-hash/hashed-file-mapping.ts b/src/modules/dynamic-hash/hashed-file-mapping.ts new file mode 100644 index 00000000000..a3348b3fd1b --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.ts @@ -0,0 +1,30 @@ +/** + * 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/ + */ +export const ID = 'hashed-file-mapping'; + +/** + * A mapping of plain path to hashed path, used for cache-busting files that are created dynamically after DSpace has been built. + * The production server can register hashed files here and attach the map to index.html. + * The browser can then use it to resolve hashed files and circumvent the browser cache. + * + * This can ensure that e.g. configuration changes are consistently served to all CSR clients. + */ +export abstract class HashedFileMapping { + protected readonly map: Map = new Map(); + + /** + * Resolve a hashed path based on a plain path. + */ + resolve(plainPath: string): string { + if (this.map.has(plainPath)) { + const hashPath = this.map.get(plainPath); + return hashPath; + } + return plainPath; + } +}