From d336d8336178dcd4a5d822df188f2d47aca73599 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Thu, 13 Feb 2025 17:20:33 +0100 Subject: [PATCH 1/8] Cache-bust dynamic configuration files DSpace Angular's production server can modify the configuration it serves to CSR-only clients through YAML or environment variables. However, these files can remain cached in the browser and leave users with out-of-date configuration until the TTL runs out or the user does a "hard refresh". On build time this sort of problem is solved by saving the content hash as part of the path of each file (JS, CSS, ...) We introduce HashedFileMapping to bridge the same gap for dynamic content generated _after_ the server is built: - Files added to this mapping on the server are copied to a hashed path - A copy is injected into index.html, where clients can read it out and resolve the hashed paths - If a given path is not found in the mapping, the client will fall back to the original version (to prevent errors in development mode) With this mechanism we can ensure updates to config.json and similar files take immediate effect without losing the performance benefit of client-side caching. --- package.json | 1 + server.ts | 12 +- src/config/config.server.ts | 11 +- src/main.browser.ts | 5 +- .../hashed-file-mapping.browser.ts | 38 ++++++ .../hashed-file-mapping.server.ts | 115 ++++++++++++++++++ .../dynamic-hash/hashed-file-mapping.ts | 30 +++++ yarn.lock | 26 +++- 8 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 src/modules/dynamic-hash/hashed-file-mapping.browser.ts create mode 100644 src/modules/dynamic-hash/hashed-file-mapping.server.ts create mode 100644 src/modules/dynamic-hash/hashed-file-mapping.ts diff --git a/package.json b/package.json index 719b13b23b6..d747d057782 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "ngx-pagination": "6.0.3", "ngx-sortablejs": "^11.1.0", "ngx-ui-switch": "^14.0.3", + "node-html-parser": "^7.0.1", "nouislider": "^14.6.3", "pem": "1.14.7", "prop-types": "^15.8.1", diff --git a/server.ts b/server.ts index 23327c2058e..cee6fc73916 100644 --- a/server.ts +++ b/server.ts @@ -52,6 +52,7 @@ import { ServerAppModule } from './src/main.server'; import { buildAppConfig } from './src/config/config.server'; import { APP_CONFIG, 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 './src/app/core/auth/models/auth-token-info.model'; @@ -67,7 +68,10 @@ 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); +hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode let botCache: LRU; @@ -256,7 +260,7 @@ function ngApp(req, res) { */ function serverSideRender(req, res, sendToUser: boolean = true) { // Render the page via SSR (server side rendering) - res.render(indexHtml, { + res.render(hashedFileMapping.resolve(indexHtml), { req, res, preboot: environment.universal.preboot, @@ -298,7 +302,7 @@ function serverSideRender(req, res, sendToUser: boolean = true) { * @param res current response */ function clientSideRender(req, res) { - res.sendFile(indexHtml); + res.sendFile(hashedFileMapping.resolve(indexHtml)); } @@ -487,7 +491,7 @@ function saveToCache(req, page: any) { */ function hasNotSucceeded(statusCode) { const rgx = new RegExp(/^20+/); - return !rgx.test(statusCode) + return !rgx.test(statusCode); } function retrieveHeaders(response) { diff --git a/src/config/config.server.ts b/src/config/config.server.ts index 65daede0a84..258c94567b6 100644 --- a/src/config/config.server.ts +++ b/src/config/config.server.ts @@ -2,6 +2,7 @@ import { red, blue, green, bold } from 'colors'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { load } from 'js-yaml'; import { join } from 'path'; +import { ServerHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.server'; import { AppConfig } from './app-config.interface'; import { Config } from './config.interface'; @@ -159,6 +160,7 @@ const buildBaseUrl = (config: ServerConfig): void => { ].join(''); }; + /** * Build app config with the following chain of override. * @@ -169,7 +171,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(); @@ -236,7 +238,12 @@ 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); + } console.log(`Angular ${bold('config.json')} file generated correctly at ${bold(destConfigPath)} \n`); } diff --git a/src/main.browser.ts b/src/main.browser.ts index 43b2ffbaf40..1b94d2de752 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -3,14 +3,15 @@ import 'reflect-metadata'; import 'core-js/es/reflect'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - import { BrowserAppModule } from './modules/app/browser-app.module'; import { environment } from './environments/environment'; import { AppConfig } from './config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './config/config.util'; import { enableProdMode } from '@angular/core'; +import { BrowserHashedFileMapping } from './modules/dynamic-hash/hashed-file-mapping.browser'; +const hashedFileMapping = new BrowserHashedFileMapping(); const bootstrap = () => platformBrowserDynamic() .bootstrapModule(BrowserAppModule, {}); @@ -32,7 +33,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((appConfig: 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..02df8d6d545 --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.browser.ts @@ -0,0 +1,38 @@ +/** + * 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 { Injectable } 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() { + 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..c709bab2782 --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -0,0 +1,115 @@ +/** + * 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 'crypto'; +import { + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import glob from 'glob'; +import { parse } from 'node-html-parser'; +import { + extname, + join, + relative, +} from 'path'; +import zlib from 'zlib'; +import { + HashedFileMapping, + ID, +} from './hashed-file-mapping'; + +/** + * 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; + + 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) { + if (content === undefined) { + readFileSync(path); + } + + // remove previous files + const ext = extname(path); + glob.GlobSync(path.replace(`${ext}`, `.*${ext}*`)) + .found + .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); + } + }); + } + } + + /** + * 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; + }, {}); + + let root = parse(this.indexContent); + root.querySelector(`head > script#${ID}`)?.remove(); + root.querySelector('head') + .appendChild(`` as any); + + this.add(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; + } +} diff --git a/yarn.lock b/yarn.lock index 730966fcdb5..8f60a5d4127 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4372,6 +4372,17 @@ css-select@^4.2.0, css-select@^4.3.0: domutils "^2.8.0" nth-check "^2.0.1" +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + css-vendor@^2.0.8: version "2.0.8" resolved "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz" @@ -4380,7 +4391,7 @@ css-vendor@^2.0.8: "@babel/runtime" "^7.8.3" is-in-browser "^1.0.2" -css-what@^6.0.1: +css-what@^6.0.1, css-what@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== @@ -6184,6 +6195,11 @@ hdr-histogram-percentiles-obj@^3.0.0: resolved "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz" integrity sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw== +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" @@ -8277,6 +8293,14 @@ node-gyp@^9.0.0: tar "^6.1.2" which "^2.0.2" +node-html-parser@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-7.0.1.tgz#e3056550bae48517ebf161a0b0638f4b0123dfe3" + integrity sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA== + dependencies: + css-select "^5.1.0" + he "1.2.0" + node-releases@^2.0.8: version "2.0.10" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz" From fdc6e8aaec25ab11f9cee3bcb93e65ce1415e907 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Mon, 17 Feb 2025 12:33:37 +0100 Subject: [PATCH 2/8] Don't try to 'run' JSON on load The JSON mapping needs to be declared as a data block, otherwise browers may complain (nothing seems to really break though, except for Cypress) See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#any_other_value --- src/modules/dynamic-hash/hashed-file-mapping.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/dynamic-hash/hashed-file-mapping.server.ts b/src/modules/dynamic-hash/hashed-file-mapping.server.ts index c709bab2782..41e83e6b19c 100644 --- a/src/modules/dynamic-hash/hashed-file-mapping.server.ts +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -106,9 +106,9 @@ export class ServerHashedFileMapping extends HashedFileMapping { }, {}); let root = parse(this.indexContent); - root.querySelector(`head > script#${ID}`)?.remove(); + root.querySelector(`script#${ID}`)?.remove(); root.querySelector('head') - .appendChild(`` as any); + .appendChild(`` as any); this.add(this.indexPath, root.toString()); } From 2f235991057154531f6d244e4b5df59b17078dd6 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Mon, 17 Feb 2025 17:10:23 +0100 Subject: [PATCH 3/8] Add theme CSS to hashed file map --- server.ts | 1 + src/app/app.module.ts | 6 +++++ .../theme-support/theme.service.spec.ts | 13 +++++++++ src/app/shared/theme-support/theme.service.ts | 7 ++++- src/main.browser.ts | 2 +- .../hashed-file-mapping.browser.ts | 13 ++++++--- .../hashed-file-mapping.server.ts | 27 +++++++++++++++++-- 7 files changed, 62 insertions(+), 7 deletions(-) diff --git a/server.ts b/server.ts index cee6fc73916..e46f044923e 100644 --- a/server.ts +++ b/server.ts @@ -71,6 +71,7 @@ const cookieParser = require('cookie-parser'); const configJson = join(DIST_FOLDER, 'assets/config.json'); const hashedFileMapping = new ServerHashedFileMapping(DIST_FOLDER, 'index.html'); const appConfig: AppConfig = buildAppConfig(configJson, hashedFileMapping); +hashedFileMapping.addThemeStyles(); hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 89e361821ba..3ba227c134a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,6 +10,8 @@ import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/sto import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; +import { HashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping'; +import { BrowserHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.browser'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @@ -103,6 +105,10 @@ const PROVIDERS = [ useClass: LogInterceptor, multi: true }, + { + provide: HashedFileMapping, + useClass: BrowserHashedFileMapping, + }, // register the dynamic matcher used by form. MUST be provided by the app module ...DYNAMIC_MATCHER_PROVIDERS, ]; diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index f56fb86cbc1..677bf065f6c 100644 --- a/src/app/shared/theme-support/theme.service.spec.ts +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -1,6 +1,7 @@ import { of as observableOf } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; +import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { LinkService } from '../../core/cache/builders/link.service'; import { hot } from 'jasmine-marbles'; import { SetThemeAction } from './theme.actions'; @@ -46,6 +47,10 @@ class MockLinkService { } } +const mockHashedFileMapping = { + resolve: (path: string) => path, +}; + describe('ThemeService', () => { let themeService: ThemeService; let linkService: LinkService; @@ -96,6 +101,10 @@ describe('ThemeService', () => { provideMockActions(() => mockActions), { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, + { + provide: HashedFileMapping, + useValue: mockHashedFileMapping, + }, ] }); @@ -393,6 +402,10 @@ describe('ThemeService', () => { provideMockStore({ initialState }), { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, + { + provide: HashedFileMapping, + useValue: mockHashedFileMapping, + }, ] }); diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 6d2939a5f88..8ffa3a2eb1c 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject, Injector } from '@angular/core'; import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; import { BehaviorSubject, EMPTY, Observable, of as observableOf } from 'rxjs'; +import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { ThemeState } from './theme.reducer'; import { SetThemeAction, ThemeActionTypes } from './theme.actions'; import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; @@ -57,6 +58,7 @@ export class ThemeService { @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig, private router: Router, @Inject(DOCUMENT) private document: any, + private hashedFileMapping: HashedFileMapping, ) { // Create objects from the theme configs in the environment file this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig, injector)); @@ -182,7 +184,10 @@ export class ThemeService { 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(`${encodeURIComponent(themeName)}-theme.css`), + ); // 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/main.browser.ts b/src/main.browser.ts index 1b94d2de752..a73494cda2a 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -11,7 +11,7 @@ import { extendEnvironmentWithAppConfig } from './config/config.util'; import { enableProdMode } from '@angular/core'; import { BrowserHashedFileMapping } from './modules/dynamic-hash/hashed-file-mapping.browser'; -const hashedFileMapping = new BrowserHashedFileMapping(); +const hashedFileMapping = new BrowserHashedFileMapping(document); const bootstrap = () => platformBrowserDynamic() .bootstrapModule(BrowserAppModule, {}); diff --git a/src/modules/dynamic-hash/hashed-file-mapping.browser.ts b/src/modules/dynamic-hash/hashed-file-mapping.browser.ts index 02df8d6d545..576dc386314 100644 --- a/src/modules/dynamic-hash/hashed-file-mapping.browser.ts +++ b/src/modules/dynamic-hash/hashed-file-mapping.browser.ts @@ -5,7 +5,12 @@ * * http://www.dspace.org/license/ */ -import { Injectable } from '@angular/core'; +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'; @@ -21,9 +26,11 @@ import { */ @Injectable() export class BrowserHashedFileMapping extends HashedFileMapping { - constructor() { + constructor( + @Optional() @Inject(DOCUMENT) protected document: any, + ) { super(); - const element = document.querySelector(`script#${ID}`); + const element = document?.querySelector(`script#${ID}`); if (hasValue(element?.textContent)) { const mapping = JSON.parse(element.textContent); diff --git a/src/modules/dynamic-hash/hashed-file-mapping.server.ts b/src/modules/dynamic-hash/hashed-file-mapping.server.ts index 41e83e6b19c..822b3f5864f 100644 --- a/src/modules/dynamic-hash/hashed-file-mapping.server.ts +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -10,6 +10,8 @@ import { readFileSync, rmSync, writeFileSync, + copyFileSync, + existsSync, } from 'fs'; import glob from 'glob'; import { parse } from 'node-html-parser'; @@ -48,9 +50,9 @@ export class ServerHashedFileMapping extends HashedFileMapping { * Otherwise, it is read out from the original path. * The original path is never overwritten. */ - add(path: string, content?: string, compress = false) { + add(path: string, content?: string, compress = false): string { if (content === undefined) { - readFileSync(path); + content = readFileSync(path).toString(); } // remove previous files @@ -92,6 +94,27 @@ export class ServerHashedFileMapping extends HashedFileMapping { } }); } + + return hashPath; + } + + addThemeStyles() { + glob.GlobSync(`${this.root}/*-theme.css`) + .found + .forEach(p => { + const hp = this.add(p); + this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.br'); + this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.gz'); + }); + } + + private ensureCompressedFilesAssumingUnchangedContent(path: string, hashedPath: string, compression: string) { + const compressedPath = `${path}${compression}`; + const compressedHashedPath = `${hashedPath}${compression}`; + + if (existsSync(compressedPath) && !existsSync(compressedHashedPath)) { + copyFileSync(compressedPath, compressedHashedPath); + } } /** From ce4dd3aa3254be96a8e28b1d5090b2cdbce001b5 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Mon, 24 Feb 2025 13:21:00 +0100 Subject: [PATCH 4/8] Simplify testing by making mapping optional Mainly for 8.x and beyond, where ThemeService is injected more often ~ standalone components --- src/app/shared/theme-support/theme.service.spec.ts | 12 ------------ src/app/shared/theme-support/theme.service.ts | 12 +++++++++--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index 677bf065f6c..1eccb5088dd 100644 --- a/src/app/shared/theme-support/theme.service.spec.ts +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -47,10 +47,6 @@ class MockLinkService { } } -const mockHashedFileMapping = { - resolve: (path: string) => path, -}; - describe('ThemeService', () => { let themeService: ThemeService; let linkService: LinkService; @@ -101,10 +97,6 @@ describe('ThemeService', () => { provideMockActions(() => mockActions), { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, - { - provide: HashedFileMapping, - useValue: mockHashedFileMapping, - }, ] }); @@ -402,10 +394,6 @@ describe('ThemeService', () => { provideMockStore({ initialState }), { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, - { - provide: HashedFileMapping, - useValue: mockHashedFileMapping, - }, ] }); diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 8ffa3a2eb1c..0fb957b7260 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Inject, Injector } from '@angular/core'; +import { + Injectable, + Inject, + Injector, + Optional, +} from '@angular/core'; import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; import { BehaviorSubject, EMPTY, Observable, of as observableOf } from 'rxjs'; import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; @@ -58,7 +63,7 @@ export class ThemeService { @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig, private router: Router, @Inject(DOCUMENT) private document: any, - private hashedFileMapping: HashedFileMapping, + @Optional() private hashedFileMapping: HashedFileMapping, ) { // Create objects from the theme configs in the environment file this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig, injector)); @@ -181,12 +186,13 @@ 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', - this.hashedFileMapping.resolve(`${encodeURIComponent(themeName)}-theme.css`), + this.hashedFileMapping?.resolve(themeCSS) ?? themeCSS, ); // wait for the new css to download before removing the old one to prevent a // flash of unstyled content From 928e932b06eb1eb78364ebd940ae7d36430ac3aa Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Mon, 17 Mar 2025 15:29:53 +0100 Subject: [PATCH 5/8] Optionally preload hashed files Both the configuration and theme CSS are known to be needed for every render. We can save some time by retrieving them as soon as the HTML is read. --- server.ts | 2 +- src/config/config.server.ts | 8 ++- .../hashed-file-mapping.server.ts | 51 +++++++++++++++---- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/server.ts b/server.ts index e46f044923e..3083b9ed4f8 100644 --- a/server.ts +++ b/server.ts @@ -71,7 +71,7 @@ const cookieParser = require('cookie-parser'); const configJson = join(DIST_FOLDER, 'assets/config.json'); const hashedFileMapping = new ServerHashedFileMapping(DIST_FOLDER, 'index.html'); const appConfig: AppConfig = buildAppConfig(configJson, hashedFileMapping); -hashedFileMapping.addThemeStyles(); +hashedFileMapping.addThemeStyles(appConfig.themes); hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode diff --git a/src/config/config.server.ts b/src/config/config.server.ts index 258c94567b6..231affcfc71 100644 --- a/src/config/config.server.ts +++ b/src/config/config.server.ts @@ -2,9 +2,11 @@ import { red, blue, green, bold } from 'colors'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { load } from 'js-yaml'; import { join } from 'path'; +import { environment } from '../environments/environment'; 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 { DefaultAppConfig } from './default-app-config'; import { ServerConfig } from './server-config.interface'; @@ -173,7 +175,7 @@ const buildBaseUrl = (config: ServerConfig): void => { */ export const buildAppConfig = (destConfigPath?: string, mapping?: ServerHashedFileMapping): AppConfig => { // start with default app config - const appConfig: AppConfig = new DefaultAppConfig(); + const appConfig: BuildConfig = new DefaultAppConfig() as BuildConfig; // determine which dist app config by environment const env = getEnvironment(); @@ -243,6 +245,10 @@ export const buildAppConfig = (destConfigPath?: string, mapping?: ServerHashedFi writeFileSync(destConfigPath, content); if (mapping !== undefined) { mapping.add(destConfigPath, content); + if (!appConfig.universal.preboot) { + // If we're serving for CSR we can retrieve the configuration before JS is loaded/executed + mapping.addHeadLink(destConfigPath, 'preload', 'fetch', 'anonymous'); + } } console.log(`Angular ${bold('config.json')} file generated correctly at ${bold(destConfigPath)} \n`); diff --git a/src/modules/dynamic-hash/hashed-file-mapping.server.ts b/src/modules/dynamic-hash/hashed-file-mapping.server.ts index 822b3f5864f..8c184f110c5 100644 --- a/src/modules/dynamic-hash/hashed-file-mapping.server.ts +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -7,11 +7,11 @@ */ import crypto from 'crypto'; import { + copyFileSync, + existsSync, readFileSync, rmSync, writeFileSync, - copyFileSync, - existsSync, } from 'fs'; import glob from 'glob'; import { parse } from 'node-html-parser'; @@ -21,6 +21,8 @@ import { relative, } from 'path'; import zlib from 'zlib'; +import { hasValue } from '../../app/shared/empty.util'; +import { ThemeConfig } from '../../config/theme.model'; import { HashedFileMapping, ID, @@ -34,6 +36,8 @@ export class ServerHashedFileMapping extends HashedFileMapping { public readonly indexPath: string; private readonly indexContent: string; + protected readonly headLinks: Set = new Set(); + constructor( private readonly root: string, file: string, @@ -98,14 +102,36 @@ export class ServerHashedFileMapping extends HashedFileMapping { return hashPath; } - addThemeStyles() { - glob.GlobSync(`${this.root}/*-theme.css`) - .found - .forEach(p => { - const hp = this.add(p); - this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.br'); - this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.gz'); - }); + /** + * Add CSS for all configured themes to the mapping + * @param themeConfigurations + */ + addThemeStyles(themeConfigurations: ThemeConfig[]) { + for (const themeConfiguration of themeConfigurations) { + const p = `${this.root}/${themeConfiguration.name}-theme.css`; + const hp = this.add(p); + + // We know this CSS is likely needed, so wecan avoid a FOUC by retrieving it in advance + // Angular does the same for global styles, but doesn't "know" about out themes + this.addHeadLink(p, 'prefetch', 'style'); + + this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.br'); + this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.gz'); + } + } + + /** + * Include a head link for a given resource to the index HTML. + */ + addHeadLink(path: string, rel: string, as: string, crossorigin?: string) { + const href = relative(this.root, this.resolve(path)); + + if (hasValue(crossorigin)) { + this.headLinks.add(``); + + } else { + this.headLinks.add(``); + } } private ensureCompressedFilesAssumingUnchangedContent(path: string, hashedPath: string, compression: string) { @@ -133,6 +159,11 @@ export class ServerHashedFileMapping extends HashedFileMapping { root.querySelector('head') .appendChild(`` as any); + for (const headLink of this.headLinks) { + root.querySelector('head') + .appendChild(headLink as any); + } + this.add(this.indexPath, root.toString()); } } From c6bf2b7355c3c7ba493c3b2688709a9125740517 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Tue, 18 Mar 2025 17:32:50 +0100 Subject: [PATCH 6/8] Don't hash or cache the index file This didn't work well in one specific case: requests without a path (e.g. https://demo.dspace.org) Such requests would result in a 304 redirect directly to index.html, losing the hashed file mapping and causing it to get cached. Then it could remain in the cache across rebuilds. Solutions: - Don't try to hash index.html, but modify it in place - Introduce configuration to disable caching for specific static files and apply this to index.html to prevent similar problems - Don't let browsers cache index.html when it's served for CSR under another path --- config/config.example.yml | 3 ++ server.ts | 14 ++++++-- .../theme-support/theme.service.spec.ts | 1 - src/config/cache-config.interface.ts | 2 ++ src/config/config.server.ts | 12 ++++--- src/config/default-app-config.ts | 4 +++ src/environments/environment.test.ts | 4 +++ .../hashed-file-mapping.server.ts | 36 +++++++++++++------ 8 files changed, 58 insertions(+), 18 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index ea38303fa36..1e4397df8e7 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -38,6 +38,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 diff --git a/server.ts b/server.ts index 3083b9ed4f8..827cb455281 100644 --- a/server.ts +++ b/server.ts @@ -261,7 +261,7 @@ function ngApp(req, res) { */ function serverSideRender(req, res, sendToUser: boolean = true) { // Render the page via SSR (server side rendering) - res.render(hashedFileMapping.resolve(indexHtml), { + res.render(indexHtml, { req, res, preboot: environment.universal.preboot, @@ -303,7 +303,11 @@ function serverSideRender(req, res, sendToUser: boolean = true) { * @param res current response */ function clientSideRender(req, res) { - res.sendFile(hashedFileMapping.resolve(indexHtml)); + res.sendFile(indexHtml, { + headers: { + 'Cache-Control': 'no-cache, no-store', + }, + }); } @@ -314,7 +318,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.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index 1eccb5088dd..f56fb86cbc1 100644 --- a/src/app/shared/theme-support/theme.service.spec.ts +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -1,7 +1,6 @@ import { of as observableOf } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { LinkService } from '../../core/cache/builders/link.service'; import { hot } from 'jasmine-marbles'; import { SetThemeAction } from './theme.actions'; diff --git a/src/config/cache-config.interface.ts b/src/config/cache-config.interface.ts index 73520c95ea1..fd7719a037c 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 231affcfc71..c74a5ef9c8b 100644 --- a/src/config/config.server.ts +++ b/src/config/config.server.ts @@ -2,7 +2,6 @@ import { red, blue, green, bold } from 'colors'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { load } from 'js-yaml'; import { join } from 'path'; -import { environment } from '../environments/environment'; import { ServerHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.server'; import { AppConfig } from './app-config.interface'; @@ -175,7 +174,7 @@ const buildBaseUrl = (config: ServerConfig): void => { */ export const buildAppConfig = (destConfigPath?: string, mapping?: ServerHashedFileMapping): AppConfig => { // start with default app config - const appConfig: BuildConfig = new DefaultAppConfig() as BuildConfig; + const appConfig: AppConfig = new DefaultAppConfig(); // determine which dist app config by environment const env = getEnvironment(); @@ -245,9 +244,14 @@ export const buildAppConfig = (destConfigPath?: string, mapping?: ServerHashedFi writeFileSync(destConfigPath, content); if (mapping !== undefined) { mapping.add(destConfigPath, content); - if (!appConfig.universal.preboot) { + if (!(appConfig as BuildConfig).universal?.preboot) { // If we're serving for CSR we can retrieve the configuration before JS is loaded/executed - mapping.addHeadLink(destConfigPath, 'preload', 'fetch', 'anonymous'); + mapping.addHeadLink({ + path: destConfigPath, + rel: 'preload', + as: 'fetch', + crossorigin: 'anonymous', + }); } } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index a6e9e092e46..b2eb51ec9bf 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -70,6 +70,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/environments/environment.test.ts b/src/environments/environment.test.ts index cb9d2c71303..ecfd520cb18 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -51,6 +51,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/modules/dynamic-hash/hashed-file-mapping.server.ts b/src/modules/dynamic-hash/hashed-file-mapping.server.ts index 8c184f110c5..b922fc90661 100644 --- a/src/modules/dynamic-hash/hashed-file-mapping.server.ts +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -28,6 +28,15 @@ import { 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. @@ -36,7 +45,7 @@ export class ServerHashedFileMapping extends HashedFileMapping { public readonly indexPath: string; private readonly indexContent: string; - protected readonly headLinks: Set = new Set(); + protected readonly headLinks: Set = new Set(); constructor( private readonly root: string, @@ -113,7 +122,11 @@ export class ServerHashedFileMapping extends HashedFileMapping { // We know this CSS is likely needed, so wecan avoid a FOUC by retrieving it in advance // Angular does the same for global styles, but doesn't "know" about out themes - this.addHeadLink(p, 'prefetch', 'style'); + this.addHeadLink({ + path: p, + rel: 'prefetch', + as: 'style', + }); this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.br'); this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.gz'); @@ -123,14 +136,17 @@ export class ServerHashedFileMapping extends HashedFileMapping { /** * Include a head link for a given resource to the index HTML. */ - addHeadLink(path: string, rel: string, as: string, crossorigin?: string) { - const href = relative(this.root, this.resolve(path)); + addHeadLink(headLink: HeadLink) { + this.headLinks.add(headLink); + } - if (hasValue(crossorigin)) { - this.headLinks.add(``); + private renderHeadLink(link: HeadLink): string { + const href = relative(this.root, this.resolve(link.path)); + if (hasValue(link.crossorigin)) { + return ``; } else { - this.headLinks.add(``); + return ``; } } @@ -155,15 +171,15 @@ export class ServerHashedFileMapping extends HashedFileMapping { }, {}); let root = parse(this.indexContent); - root.querySelector(`script#${ID}`)?.remove(); + root.querySelectorAll(`script#${ID}, link.${HEAD_LINK_CLASS}`)?.forEach(e => e.remove()); root.querySelector('head') .appendChild(`` as any); for (const headLink of this.headLinks) { root.querySelector('head') - .appendChild(headLink as any); + .appendChild(this.renderHeadLink(headLink) as any); } - this.add(this.indexPath, root.toString()); + writeFileSync(this.indexPath, root.toString()); } } From 93da6ce34cded5b29c4e7dcb0aa1f50fbb2eb87f Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Wed, 16 Apr 2025 15:28:52 +0200 Subject: [PATCH 7/8] Fix one-letter variable names --- .../dynamic-hash/hashed-file-mapping.server.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/dynamic-hash/hashed-file-mapping.server.ts b/src/modules/dynamic-hash/hashed-file-mapping.server.ts index b922fc90661..96e3af85664 100644 --- a/src/modules/dynamic-hash/hashed-file-mapping.server.ts +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -117,19 +117,19 @@ export class ServerHashedFileMapping extends HashedFileMapping { */ addThemeStyles(themeConfigurations: ThemeConfig[]) { for (const themeConfiguration of themeConfigurations) { - const p = `${this.root}/${themeConfiguration.name}-theme.css`; - const hp = this.add(p); + const path = `${this.root}/${themeConfiguration.name}-theme.css`; + const hashPath = this.add(path); - // We know this CSS is likely needed, so wecan avoid a FOUC by retrieving it in advance - // Angular does the same for global styles, but doesn't "know" about out themes + // 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: p, + path, rel: 'prefetch', as: 'style', }); - this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.br'); - this.ensureCompressedFilesAssumingUnchangedContent(p, hp, '.gz'); + this.ensureCompressedFilesAssumingUnchangedContent(path, hashPath, '.br'); + this.ensureCompressedFilesAssumingUnchangedContent(path, hashPath, '.gz'); } } From d2458e6f95daec73e0c617799c7c6f761ebd983e Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Fri, 19 Sep 2025 15:15:31 +0200 Subject: [PATCH 8/8] Small improvements - Track head links in a Map, because Set doesn't deduplicate them properly - Make ServerHashedFileMapping independent of ThemeConfig - Make theme pre-fetching configurable (e.g. only prefetch the main theme CSS, but leave route-based theme CSS lazy) --- config/config.example.yml | 7 +++-- server.ts | 2 +- src/config/theme.model.ts | 5 ++++ .../hashed-file-mapping.server.ts | 28 ++++++++----------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 1e4397df8e7..22724557a40 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -78,7 +78,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -327,6 +327,7 @@ themes: # - name: BASE_THEME_NAME # - name: dspace + prefetch: true headTags: - tagName: link attributes: @@ -379,7 +380,7 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' - sortDirection: 'ASC' \ No newline at end of file + sortDirection: 'ASC' diff --git a/server.ts b/server.ts index 827cb455281..57813e1d5d3 100644 --- a/server.ts +++ b/server.ts @@ -71,7 +71,7 @@ const cookieParser = require('cookie-parser'); const configJson = join(DIST_FOLDER, 'assets/config.json'); const hashedFileMapping = new ServerHashedFileMapping(DIST_FOLDER, 'index.html'); const appConfig: AppConfig = buildAppConfig(configJson, hashedFileMapping); -hashedFileMapping.addThemeStyles(appConfig.themes); +appConfig.themes.forEach(themeConfig => hashedFileMapping.addThemeStyle(themeConfig.name, themeConfig.prefetch)); hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode diff --git a/src/config/theme.model.ts b/src/config/theme.model.ts index 019540f18a8..64e16afa239 100644 --- a/src/config/theme.model.ts +++ b/src/config/theme.model.ts @@ -20,6 +20,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/modules/dynamic-hash/hashed-file-mapping.server.ts b/src/modules/dynamic-hash/hashed-file-mapping.server.ts index 96e3af85664..9ab01eba49c 100644 --- a/src/modules/dynamic-hash/hashed-file-mapping.server.ts +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -22,7 +22,6 @@ import { } from 'path'; import zlib from 'zlib'; import { hasValue } from '../../app/shared/empty.util'; -import { ThemeConfig } from '../../config/theme.model'; import { HashedFileMapping, ID, @@ -45,7 +44,7 @@ export class ServerHashedFileMapping extends HashedFileMapping { public readonly indexPath: string; private readonly indexContent: string; - protected readonly headLinks: Set = new Set(); + protected readonly headLinks: Map = new Map(); constructor( private readonly root: string, @@ -70,7 +69,7 @@ export class ServerHashedFileMapping extends HashedFileMapping { // remove previous files const ext = extname(path); - glob.GlobSync(path.replace(`${ext}`, `.*${ext}*`)) + new glob.GlobSync(path.replace(`${ext}`, `.*${ext}*`)) .found .forEach(p => rmSync(p)); @@ -111,15 +110,11 @@ export class ServerHashedFileMapping extends HashedFileMapping { return hashPath; } - /** - * Add CSS for all configured themes to the mapping - * @param themeConfigurations - */ - addThemeStyles(themeConfigurations: ThemeConfig[]) { - for (const themeConfiguration of themeConfigurations) { - const path = `${this.root}/${themeConfiguration.name}-theme.css`; - const hashPath = this.add(path); + 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({ @@ -127,17 +122,18 @@ export class ServerHashedFileMapping extends HashedFileMapping { rel: 'prefetch', as: 'style', }); - - this.ensureCompressedFilesAssumingUnchangedContent(path, hashPath, '.br'); - this.ensureCompressedFilesAssumingUnchangedContent(path, hashPath, '.gz'); } + + // 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.add(headLink); + this.headLinks.set(headLink.path, headLink); } private renderHeadLink(link: HeadLink): string { @@ -175,7 +171,7 @@ export class ServerHashedFileMapping extends HashedFileMapping { root.querySelector('head') .appendChild(`` as any); - for (const headLink of this.headLinks) { + for (const headLink of this.headLinks.values()) { root.querySelector('head') .appendChild(this.renderHeadLink(headLink) as any); }