diff --git a/config/config.example.yml b/config/config.example.yml index 5fa2e74cbbc..b26b20115aa 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -550,3 +550,13 @@ liveRegion: messageTimeOutDurationMs: 30000 # The visibility of the live region. Setting this to true is only useful for debugging purposes. isVisible: false + + +# REST response prefetching configuration +prefetch: + # The URLs for which the response will be prefetched + urls: + - /api + - /api/discover + # How often the responses are refreshed in milliseconds + refreshInterval: 60000 diff --git a/package-lock.json b/package-lock.json index 27a9b18050e..504b0ca1279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^15.0.0", + "node-html-parser": "^7.0.1", "nouislider": "^15.7.1", "orejime": "^2.3.1", "pem": "1.14.8", @@ -13994,6 +13995,14 @@ "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==", + "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", @@ -17812,6 +17821,15 @@ "node": "^16.13.0 || >=18.0.0" } }, + "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==", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", diff --git a/package.json b/package.json index c289171423d..2d89491724e 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^15.0.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 52b2823d49c..28fa545683e 100644 --- a/server.ts +++ b/server.ts @@ -44,12 +44,13 @@ import { createProxyMiddleware } from 'http-proxy-middleware'; 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 { buildAppConfig, setupEndpointPrefetching } 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'; import { CommonEngine } from '@angular/ssr'; @@ -70,7 +71,11 @@ const indexHtml = join(DIST_FOLDER, 'index.html'); const cookieParser = require('cookie-parser'); -const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); +const destConfigPath = join(DIST_FOLDER, 'assets/config.json'); +const hashedFileMapping = new ServerHashedFileMapping(DIST_FOLDER, 'index.html'); +const appConfig: AppConfig = buildAppConfig(destConfigPath, hashedFileMapping); +hashedFileMapping.addThemeStyles(); +hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode let botCache: LRU; @@ -247,7 +252,7 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) { commonEngine .render({ bootstrap, - documentFilePath: indexHtml, + documentFilePath: hashedFileMapping.resolve(indexHtml), inlineCriticalCss: environment.ssr.inlineCriticalCss, url: `${protocol}://${headers.host}${originalUrl}`, publicPath: DIST_FOLDER, @@ -309,7 +314,7 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) { * @param res current response */ function clientSideRender(req, res) { - res.sendFile(indexHtml); + res.sendFile(hashedFileMapping.resolve(indexHtml)); } @@ -537,7 +542,7 @@ function serverStarted() { * Create an HTTPS server with the configured port and host * @param keys SSL credentials */ -function createHttpsServer(keys) { +function createHttpsServer(prefetchRefreshTimeout: NodeJS.Timeout, keys) { const listener = createServer({ key: keys.serviceKey, cert: keys.certificate, @@ -550,7 +555,7 @@ function createHttpsServer(keys) { process.on('SIGINT', () => { void (async ()=> { console.debug('Closing HTTPS server on signal'); - await terminator.terminate().catch(e => { console.error(e); }); + clearTimeout(prefetchRefreshTimeout);await terminator.terminate().catch(e => { console.error(e); }); console.debug('HTTPS server closed'); })(); }); @@ -559,7 +564,7 @@ function createHttpsServer(keys) { /** * Create an HTTP server with the configured port and host. */ -function run() { +function run(prefetchRefreshTimeout: NodeJS.Timeout) { const port = environment.ui.port || 4000; const host = environment.ui.host || '/'; @@ -574,13 +579,16 @@ function run() { process.on('SIGINT', () => { void (async () => { console.debug('Closing HTTP server on signal'); - await terminator.terminate().catch(e => { console.error(e); }); - console.debug('HTTP server closed.');return undefined; + clearTimeout(prefetchRefreshTimeout); + await terminator.terminate().catch(e => { + console.error(e); + }); + console.debug('HTTP server closed.'); })(); }); } -function start() { +function start(prefetchRefreshTimeout: NodeJS.Timeout) { logStartupMessage(environment); /* @@ -606,10 +614,11 @@ function start() { } if (serviceKey && certificate) { - createHttpsServer({ - serviceKey: serviceKey, - certificate: certificate, - }); + createHttpsServer(prefetchRefreshTimeout, + { + serviceKey: serviceKey, + certificate: certificate, + }); } else { console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.'); @@ -619,11 +628,11 @@ function start() { days: 1, selfSigned: true, }, (error, keys) => { - createHttpsServer(keys); + createHttpsServer(prefetchRefreshTimeout, keys); }); } } else { - run(); + run(prefetchRefreshTimeout); } } @@ -648,8 +657,12 @@ function healthCheck(req, res) { declare const __non_webpack_require__: NodeRequire; const mainModule = __non_webpack_require__.main; const moduleFilename = (mainModule && mainModule.filename) || ''; -if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { - start(); -} +setupEndpointPrefetching(appConfig, destConfigPath, environment, hashedFileMapping).then(prefetchRefreshTimeout => { + if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { + start(prefetchRefreshTimeout); + } +}).catch((error) => { + console.error('Errored while prefetching Endpoint Maps', error); +}); export * from './src/main.server'; diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts index ae4df271ae6..6d351d848c7 100644 --- a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts +++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts @@ -22,7 +22,9 @@ import { of as observableOf } from 'rxjs'; import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service'; import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model'; import { TranslateLoaderMock } from 'src/app/shared/mocks/translate-loader.mock'; +import { environment } from 'src/environments/environment.test'; +import { APP_CONFIG } from '../../../../config/app-config.interface'; import { FilteredCollectionsComponent } from './filtered-collections.component'; describe('FiltersComponent', () => { @@ -57,6 +59,7 @@ describe('FiltersComponent', () => { DspaceRestService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: APP_CONFIG, useValue: environment }, ], }); })); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 77b29206cb5..5cf201bb761 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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 { @@ -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(), diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index b137b2713c3..d04b2f5f9dd 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -11,7 +11,9 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; +import { environment } from 'src/environments/environment.test'; +import { APP_CONFIG } from '../../../config/app-config.interface'; import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RouterStub } from '../../shared/testing/router.stub'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; @@ -45,6 +47,7 @@ describe(`AuthInterceptor`, () => { { provide: Store, useValue: store }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 9c0d0d16c95..be67d59809b 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,16 +1,7 @@ // eslint-disable-next-line max-classes-per-file import { Injectable } from '@angular/core'; -import { - Observable, - of as observableOf, -} from 'rxjs'; -import { take } from 'rxjs/operators'; +import { Observable } from 'rxjs'; -import { - hasValue, - isNotEmpty, - isNotEmptyOperator, -} from '../../shared/empty.util'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; @@ -27,47 +18,10 @@ import { import { FindListOptions } from '../data/find-list-options.model'; import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; -import { BrowseDefinitionRestRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -/** - * Create a GET request for the given href, and send it. - * Use a GET request specific for BrowseDefinitions. - */ -export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestService, - responseMsToLive: number, - href$: string | Observable, - useCachedVersionIfAvailable: boolean = true): void => { - if (isNotEmpty(href$)) { - if (typeof href$ === 'string') { - href$ = observableOf(href$); - } - - href$.pipe( - isNotEmptyOperator(), - take(1), - ).subscribe((href: string) => { - const requestId = requestService.generateRequestId(); - const request = new BrowseDefinitionRestRequest(requestId, href); - if (hasValue(responseMsToLive)) { - request.responseMsToLive = responseMsToLive; - } - requestService.send(request, useCachedVersionIfAvailable); - }); - } -}; - -/** - * Custom extension of {@link FindAllDataImpl} to be able to send BrowseDefinitionRestRequests - */ -class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl { - createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable: boolean = true) { - createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); - } -} - /** * Data service responsible for retrieving browse definitions from the REST server */ @@ -75,7 +29,7 @@ class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl providedIn: 'root', }) export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { - private findAllData: BrowseDefinitionFindAllDataImpl; + private findAllData: FindAllData; private searchData: SearchDataImpl; constructor( @@ -86,7 +40,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService, useCachedVersionIfAvailable: boolean = true) { - createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); - } } diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index be3ffc0f4d7..557e94f0a40 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -36,15 +36,51 @@ const linkMap = new Map(); * @param target the typed class to map */ export function typedObject(target: TypedObject) { - typeMap.set(target.type.value, target); + typeMap.set(target.type.value, new Map([[null, target]])); +} + +/** + * Decorator function to map a ResourceType and sub-type to its class + */ +export function typedObjectWithSubType(subTypeProperty: string) { + return function(target: TypedObject) { + if (hasNoValue(target[subTypeProperty]?.value)) { + throw new Error(`Class ${(target as any).name} has no static property '${subTypeProperty}' to define as the sub-type`); + } + + if (!typeMap.has(target.type.value)) { + typeMap.set(target.type.value, new Map()); + } + if (!typeMap.get(target.type.value).has(subTypeProperty)) { + typeMap.get(target.type.value) + .set(subTypeProperty, new Map()); + } + + typeMap.get(target.type.value) + .get(subTypeProperty) + .set(target[subTypeProperty].value, target); + }; } /** * Returns the mapped class for the given type - * @param type The resource type */ -export function getClassForType(type: string | ResourceType) { - return typeMap.get(getResourceTypeValueFor(type)); +export function getClassForObject(obj: any) { + const map = typeMap.get(getResourceTypeValueFor(obj.type)); + + if (hasValue(map)) { + for (const subTypeProperty of map.keys()) { + if (subTypeProperty === null) { + // Regular class without subtype + return map.get(subTypeProperty); + } else if (hasValue(obj?.[subTypeProperty])) { + // Class with subtype + return map.get(subTypeProperty).get(getResourceTypeValueFor(obj?.[subTypeProperty])); + } + } + } + + return undefined; } /** diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 36305b4a0c4..efb44c241f1 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -47,7 +47,7 @@ import { } from '../../shared/request.operators'; import { getResourceTypeValueFor } from '../object-cache.reducer'; import { ObjectCacheService } from '../object-cache.service'; -import { getClassForType } from './build-decorators'; +import { getClassForObject } from './build-decorators'; import { LinkService } from './link.service'; @Injectable({ providedIn: 'root' }) @@ -113,7 +113,7 @@ export class RemoteDataBuildService { * @param obj The object to turn in to a class instance based on its type property */ private plainObjectToInstance(obj: any): T { - const type: GenericConstructor = getClassForType(obj.type); + const type: GenericConstructor = getClassForObject(obj); if (typeof type === 'function') { return Object.assign(new type(), obj) as T; } else { diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index f645b5a878d..d5cd2edb6c3 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -40,7 +40,7 @@ import { import { IndexName } from '../index/index-name.model'; import { GenericConstructor } from '../shared/generic-constructor'; import { HALLink } from '../shared/hal-link.model'; -import { getClassForType } from './builders/build-decorators'; +import { getClassForObject } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; import { CacheableObject } from './cacheable-object.model'; import { @@ -178,7 +178,7 @@ export class ObjectCacheService { ), map((entry: ObjectCacheEntry) => { if (hasValue(entry.data)) { - const type: GenericConstructor = getClassForType((entry.data as any).type); + const type: GenericConstructor = getClassForObject(entry.data as any); if (typeof type !== 'function') { throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); } diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 63b4961b313..fd1728c7677 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -5,7 +5,7 @@ import { hasValue, isNotEmpty, } from '../../shared/empty.util'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { getClassForObject } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/cacheable-object.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; @@ -118,7 +118,7 @@ export abstract class BaseResponseParsingService { protected deserialize(obj): any { const type: string = obj.type; if (hasValue(type)) { - const objConstructor = getClassForType(type) as GenericConstructor; + const objConstructor = getClassForObject(obj) as GenericConstructor; if (hasValue(objConstructor)) { const serializer = new this.serializerConstructor(objConstructor); diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts index 13216f8796d..d5b10c2329d 100644 --- a/src/app/core/data/base/create-data.ts +++ b/src/app/core/data/base/create-data.ts @@ -19,7 +19,7 @@ import { } from '../../../shared/empty.util'; import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { getClassForType } from '../../cache/builders/build-decorators'; +import { getClassForObject } from '../../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { CacheableObject } from '../../cache/cacheable-object.model'; import { RequestParam } from '../../cache/models/request-param.model'; @@ -87,7 +87,7 @@ export class CreateDataImpl extends BaseDataService): Observable> { const requestId = this.requestService.generateRequestId(); - const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object); + const serializedObject = new DSpaceSerializer(getClassForObject(object)).serialize(object); endpoint$.pipe( take(1), diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index 53d2ec20fc8..80d7139ee99 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,5 +1,6 @@ import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; @@ -45,19 +46,22 @@ describe('BrowseResponseParsingService', () => { it('should deserialize flatBrowses correctly', () => { let deserialized = service.deserialize(mockFlatBrowse); - expect(deserialized.type).toBe(FLAT_BROWSE_DEFINITION); + expect(deserialized.type).toBe(BROWSE_DEFINITION); + expect(deserialized.browseType).toBe(FLAT_BROWSE_DEFINITION); expect(deserialized.id).toBe(mockFlatBrowse.id); }); it('should deserialize valueList browses correctly', () => { let deserialized = service.deserialize(mockValueList); - expect(deserialized.type).toBe(VALUE_LIST_BROWSE_DEFINITION); + expect(deserialized.type).toBe(BROWSE_DEFINITION); + expect(deserialized.browseType).toBe(VALUE_LIST_BROWSE_DEFINITION); expect(deserialized.id).toBe(mockValueList.id); }); it('should deserialize hierarchicalBrowses correctly', () => { let deserialized = service.deserialize(mockHierarchicalBrowse); - expect(deserialized.type).toBe(HIERARCHICAL_BROWSE_DEFINITION); + expect(deserialized.type).toBe(BROWSE_DEFINITION); + expect(deserialized.browseType).toBe(HIERARCHICAL_BROWSE_DEFINITION); expect(deserialized.id).toBe(mockHierarchicalBrowse.id); }); }); diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index fa08af018a2..49c82d955aa 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -4,7 +4,7 @@ import { Operation, } from 'fast-json-patch'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { getClassForObject } from '../cache/builders/build-decorators'; import { TypedObject } from '../cache/typed-object.model'; import { DSpaceNotNullSerializer } from '../dspace-rest/dspace-not-null.serializer'; import { ChangeAnalyzer } from './change-analyzer'; @@ -25,8 +25,8 @@ export class DefaultChangeAnalyzer implements ChangeAnaly * The second object to compare */ diff(object1: T, object2: T): Operation[] { - const serializer1 = new DSpaceNotNullSerializer(getClassForType(object1.type)); - const serializer2 = new DSpaceNotNullSerializer(getClassForType(object2.type)); + const serializer1 = new DSpaceNotNullSerializer(getClassForObject(object1)); + const serializer2 = new DSpaceNotNullSerializer(getClassForObject(object2)); return compare(serializer1.serialize(object1), serializer2.serialize(object2)); } } diff --git a/src/app/core/data/dspace-rest-response-parsing.service.ts b/src/app/core/data/dspace-rest-response-parsing.service.ts index 1cd286427f3..08754bd0606 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -7,7 +7,7 @@ import { hasValue, isNotEmpty, } from '../../shared/empty.util'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { getClassForObject } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/cacheable-object.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ParsedResponse } from '../cache/response.models'; @@ -30,7 +30,6 @@ import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; import { RestRequestMethod } from './rest-request-method'; - /** * Return true if obj has a value for `_links.self` * @@ -209,13 +208,12 @@ export class DspaceRestResponseParsingService implements ResponseParsingService } protected deserialize(obj): any { - const type = obj.type; - const objConstructor = this.getConstructorFor(type); + const objConstructor = this.getConstructorFor(obj); if (hasValue(objConstructor)) { const serializer = new this.serializerConstructor(objConstructor); return serializer.deserialize(obj); } else { - console.warn('cannot deserialize type ' + type); + console.warn('cannot deserialize type ', obj?.type); return null; } } @@ -224,15 +222,19 @@ export class DspaceRestResponseParsingService implements ResponseParsingService * Returns the constructor for the given type, or null if there isn't a registered model for that * type * - * @param type the object to find the constructor for. + * @param obj the object to find the constructor for. * @protected */ - protected getConstructorFor(type: string): GenericConstructor { - if (hasValue(type)) { - return getClassForType(type) as GenericConstructor; - } else { - return null; + protected getConstructorFor(obj: any): GenericConstructor { + if (hasValue(obj?.type)) { + const constructor = getClassForObject(obj) as GenericConstructor; + + if (hasValue(constructor)) { + return constructor; + } } + + return null; } /** diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index c7dd40b98bd..de07420bd51 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { environment } from '../../../environments/environment'; import { hasValue } from '../../shared/empty.util'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { getClassForObject } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/cacheable-object.model'; import { ParsedResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; @@ -42,7 +42,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing const type: string = processRequestDTO.type; let objConstructor; if (hasValue(type)) { - objConstructor = getClassForType(type); + objConstructor = getClassForObject(processRequestDTO); } if (isCacheableObject(processRequestDTO) && hasValue(objConstructor)) { @@ -73,7 +73,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing protected deserialize(obj): any { const type: string = obj.type; if (hasValue(type)) { - const objConstructor = getClassForType(type) as GenericConstructor; + const objConstructor = getClassForObject(obj) as GenericConstructor; if (hasValue(objConstructor)) { const serializer = new this.serializerConstructor(objConstructor); @@ -110,7 +110,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing return; } - if (hasValue(this.getConstructorFor((co as any).type))) { + if (hasValue(this.getConstructorFor(co as any))) { this.objectCache.add(co, hasValue(request.responseMsToLive) ? request.responseMsToLive : environment.cache.msToLive.default, request.uuid, alternativeURL); } } diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts index bc403bc2717..9a68c9058e0 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -21,7 +21,7 @@ import { isNotEmpty, } from '../../shared/empty.util'; import { StoreActionTypes } from '../../store.actions'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { getClassForObject } from '../cache/builders/build-decorators'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; @@ -59,7 +59,7 @@ export class RequestEffects { mergeMap((request: RestRequestWithResponseParser) => { let body = request.body; if (isNotEmpty(request.body) && !request.isMultipart) { - const serializer = new DSpaceSerializer(getClassForType(request.body.type)); + const serializer = new DSpaceSerializer(getClassForObject(request.body)); body = serializer.serialize(request.body); } return this.restApi.request(request.method, request.href, body, request.options, request.isMultipart).pipe( diff --git a/src/app/core/dspace-rest/dspace-rest.service.spec.ts b/src/app/core/dspace-rest/dspace-rest.service.spec.ts index 04f44fc942f..f29fa39d58e 100644 --- a/src/app/core/dspace-rest/dspace-rest.service.spec.ts +++ b/src/app/core/dspace-rest/dspace-rest.service.spec.ts @@ -13,6 +13,8 @@ import { TestBed, } from '@angular/core/testing'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; import { RestRequestMethod } from '../data/rest-request-method'; import { DSpaceObject } from '../shared/dspace-object.model'; import { @@ -40,6 +42,7 @@ describe('DspaceRestService', () => { DspaceRestService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/core/dspace-rest/dspace-rest.service.ts b/src/app/core/dspace-rest/dspace-rest.service.ts index a75cef53bf2..57d3ca5def2 100644 --- a/src/app/core/dspace-rest/dspace-rest.service.ts +++ b/src/app/core/dspace-rest/dspace-rest.service.ts @@ -5,24 +5,37 @@ import { HttpParams, HttpResponse, } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { + Inject, + Injectable, +} from '@angular/core'; import { Observable, + of as observableOf, throwError as observableThrowError, } from 'rxjs'; import { catchError, map, + switchMap, } from 'rxjs/operators'; +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; import { hasNoValue, + hasValue, isNotEmpty, } from '../../shared/empty.util'; import { RequestError } from '../data/request-error.model'; import { RestRequestMethod } from '../data/rest-request-method'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { RawRestResponse } from './raw-rest-response.model'; +import { + rawBootstrapToRawRestResponse, + RawRestResponse, +} from './raw-rest-response.model'; export const DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8'; export interface HttpOptions { @@ -41,8 +54,10 @@ export interface HttpOptions { @Injectable() export class DspaceRestService { - constructor(protected http: HttpClient) { - + constructor( + protected http: HttpClient, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { } /** @@ -117,6 +132,18 @@ export class DspaceRestService { // Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE); } + + return this.retrieveResponseFromConfig(method, url).pipe(switchMap(response => { + if (hasValue(response)) { + return observableOf(response); + } else { + return this.performRequest(method, url, requestOptions); + } + }), + ); + } + + performRequest(method: RestRequestMethod, url: string, requestOptions: HttpOptions): Observable { return this.http.request(method, url, requestOptions).pipe( map((res) => ({ payload: res.body, @@ -167,4 +194,20 @@ export class DspaceRestService { } } + /** + * Returns an observable which emits a RawRestResponse if the request has been prefetched and stored in the config. + * Emits null otherwise. + */ + retrieveResponseFromConfig(method: RestRequestMethod, url: string): Observable { + if (method !== RestRequestMethod.OPTIONS && method !== RestRequestMethod.GET) { + return observableOf(null); + } + + const prefetchedResponses = this.appConfig?.prefetch?.bootstrap; + if (hasValue(prefetchedResponses?.[url])) { + return observableOf(rawBootstrapToRawRestResponse(prefetchedResponses[url])); + } else { + return observableOf(null); + } + } } diff --git a/src/app/core/dspace-rest/raw-rest-response.model.ts b/src/app/core/dspace-rest/raw-rest-response.model.ts index 57f487f205d..f6ecbd22f95 100644 --- a/src/app/core/dspace-rest/raw-rest-response.model.ts +++ b/src/app/core/dspace-rest/raw-rest-response.model.ts @@ -11,3 +11,24 @@ export interface RawRestResponse { statusCode: number; statusText: string; } + +export interface RawBootstrapResponse { + payload: { + [name: string]: any; + _embedded?: any; + _links?: any; + page?: any; + }; + headers?: Record; + statusCode: number; + statusText: string; +} + +export function rawBootstrapToRawRestResponse(bootstrapResponse: RawBootstrapResponse): RawRestResponse { + return { + payload: bootstrapResponse.payload, + headers: new HttpHeaders(bootstrapResponse.headers), + statusCode: bootstrapResponse.statusCode, + statusText: bootstrapResponse.statusText, + }; +} diff --git a/src/app/core/forward-client-ip/forward-client-ip.interceptor.spec.ts b/src/app/core/forward-client-ip/forward-client-ip.interceptor.spec.ts index aa45ee5ee3b..ea4e45e58e9 100644 --- a/src/app/core/forward-client-ip/forward-client-ip.interceptor.spec.ts +++ b/src/app/core/forward-client-ip/forward-client-ip.interceptor.spec.ts @@ -8,7 +8,9 @@ import { provideHttpClientTesting, } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { environment } from 'src/environments/environment.test'; +import { APP_CONFIG } from '../../../config/app-config.interface'; import { REQUEST } from '../../../express.tokens'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; import { ForwardClientIpInterceptor } from './forward-client-ip.interceptor'; @@ -36,6 +38,7 @@ describe('ForwardClientIpInterceptor', () => { { provide: REQUEST, useValue: { get: () => undefined, connection: { remoteAddress: clientIp } } }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/core/locale/locale.interceptor.spec.ts b/src/app/core/locale/locale.interceptor.spec.ts index 4731e109e5d..d89d7794b55 100644 --- a/src/app/core/locale/locale.interceptor.spec.ts +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -10,6 +10,8 @@ import { import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; import { RestRequestMethod } from '../data/rest-request-method'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; import { LocaleInterceptor } from './locale.interceptor'; @@ -40,6 +42,7 @@ describe(`LocaleInterceptor`, () => { { provide: LocaleService, useValue: mockLocaleService }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/core/log/log.interceptor.spec.ts b/src/app/core/log/log.interceptor.spec.ts index 021526f2e18..5a2fa37b513 100644 --- a/src/app/core/log/log.interceptor.spec.ts +++ b/src/app/core/log/log.interceptor.spec.ts @@ -11,6 +11,8 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { StoreModule } from '@ngrx/store'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; import { appReducers, storeModuleConfig, @@ -58,6 +60,7 @@ describe('LogInterceptor', () => { { provide: UUIDService, useClass: UUIDService }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 78629f9d95a..09b7d2ebb77 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -1,6 +1,10 @@ import { HttpClient } from '@angular/common/http'; import { makeEnvironmentProviders } from '@angular/core'; +import { + APP_CONFIG, + AppConfig, +} from '../../config/app-config.interface'; import { environment } from '../../environments/environment'; import { Itemfilter } from '../admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters'; import { LdnService } from '../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; @@ -15,6 +19,7 @@ import { import { AccessStatusObject } from '../shared/object-collection/shared/badges/access-status-badge/access-status.model'; import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; import { Subscription } from '../shared/subscriptions/models/subscription.model'; +import { StatisticsEndpoint } from '../statistics/statistics-endpoint.model'; import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config'; import { SystemWideAlert } from '../system-wide-alert/system-wide-alert.model'; import { AuthStatus } from './auth/models/auth-status.model'; @@ -88,7 +93,7 @@ import { WorkflowAction } from './tasks/models/workflow-action-object.model'; export const provideCore = () => { return makeEnvironmentProviders([ - { provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, + { provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient, APP_CONFIG] }, ]); }; @@ -96,11 +101,11 @@ export const provideCore = () => { * When not in production, endpoint responses can be mocked for testing purposes * If there is no mock version available for the endpoint, the actual REST response will be used just like in production mode */ -export const restServiceFactory = (mocks: ResponseMapMock, http: HttpClient) => { +export const restServiceFactory = (mocks: ResponseMapMock, http: HttpClient, appConfig: AppConfig) => { if (environment.production) { - return new DspaceRestService(http); + return new DspaceRestService(http, appConfig); } else { - return new EndpointMockingRestService(mocks, http); + return new EndpointMockingRestService(mocks, http, appConfig); } }; @@ -188,4 +193,5 @@ export const models = SubmissionCoarNotifyConfig, NotifyRequestsStatus, SystemWideAlert, + StatisticsEndpoint, ]; diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index 5fe5d02ecb4..9b495ca3135 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -5,11 +5,18 @@ import { import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-data-type'; import { CacheableObject } from '../cache/cacheable-object.model'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { BROWSE_DEFINITION } from './browse-definition.resource-type'; /** * Base class for BrowseDefinition models */ export abstract class BrowseDefinition extends CacheableObject { + static type = BROWSE_DEFINITION; + + @excludeFromEquals + type = BROWSE_DEFINITION; + @autoserialize id: string; diff --git a/src/app/core/shared/flat-browse-definition.model.ts b/src/app/core/shared/flat-browse-definition.model.ts index 74166f937c9..b2a46ed1527 100644 --- a/src/app/core/shared/flat-browse-definition.model.ts +++ b/src/app/core/shared/flat-browse-definition.model.ts @@ -4,7 +4,7 @@ import { } from 'cerialize'; import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-data-type'; -import { typedObject } from '../cache/builders/build-decorators'; +import { typedObjectWithSubType } from '../cache/builders/build-decorators'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { FLAT_BROWSE_DEFINITION } from './flat-browse-definition.resource-type'; import { HALLink } from './hal-link.model'; @@ -14,16 +14,16 @@ import { ResourceType } from './resource-type'; /** * BrowseDefinition model for browses of type 'flatBrowse' */ -@typedObject +@typedObjectWithSubType('browseType') @inheritSerialization(NonHierarchicalBrowseDefinition) export class FlatBrowseDefinition extends NonHierarchicalBrowseDefinition { - static type = FLAT_BROWSE_DEFINITION; + static browseType = FLAT_BROWSE_DEFINITION; /** * The object type */ @excludeFromEquals - type: ResourceType = FLAT_BROWSE_DEFINITION; + browseType: ResourceType = FLAT_BROWSE_DEFINITION; get self(): string { return this._links.self.href; diff --git a/src/app/core/shared/hierarchical-browse-definition.model.ts b/src/app/core/shared/hierarchical-browse-definition.model.ts index eb606b7bbeb..0dc0f3aa9f3 100644 --- a/src/app/core/shared/hierarchical-browse-definition.model.ts +++ b/src/app/core/shared/hierarchical-browse-definition.model.ts @@ -5,7 +5,7 @@ import { } from 'cerialize'; import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-data-type'; -import { typedObject } from '../cache/builders/build-decorators'; +import { typedObjectWithSubType } from '../cache/builders/build-decorators'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { BrowseDefinition } from './browse-definition.model'; import { HALLink } from './hal-link.model'; @@ -15,16 +15,16 @@ import { ResourceType } from './resource-type'; /** * BrowseDefinition model for browses of type 'hierarchicalBrowse' */ -@typedObject +@typedObjectWithSubType('browseType') @inheritSerialization(BrowseDefinition) export class HierarchicalBrowseDefinition extends BrowseDefinition { - static type = HIERARCHICAL_BROWSE_DEFINITION; + static browseType = HIERARCHICAL_BROWSE_DEFINITION; /** * The object type */ @excludeFromEquals - type: ResourceType = HIERARCHICAL_BROWSE_DEFINITION; + browseType: ResourceType = HIERARCHICAL_BROWSE_DEFINITION; @autoserialize facetType: string; diff --git a/src/app/core/shared/value-list-browse-definition.model.ts b/src/app/core/shared/value-list-browse-definition.model.ts index ad9fc60d1cb..bce5e0c1f39 100644 --- a/src/app/core/shared/value-list-browse-definition.model.ts +++ b/src/app/core/shared/value-list-browse-definition.model.ts @@ -4,7 +4,7 @@ import { } from 'cerialize'; import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-data-type'; -import { typedObject } from '../cache/builders/build-decorators'; +import { typedObjectWithSubType } from '../cache/builders/build-decorators'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { HALLink } from './hal-link.model'; import { NonHierarchicalBrowseDefinition } from './non-hierarchical-browse-definition'; @@ -14,16 +14,16 @@ import { VALUE_LIST_BROWSE_DEFINITION } from './value-list-browse-definition.res /** * BrowseDefinition model for browses of type 'valueList' */ -@typedObject +@typedObjectWithSubType('browseType') @inheritSerialization(NonHierarchicalBrowseDefinition) export class ValueListBrowseDefinition extends NonHierarchicalBrowseDefinition { - static type = VALUE_LIST_BROWSE_DEFINITION; + static browseType = VALUE_LIST_BROWSE_DEFINITION; /** * The object type */ @excludeFromEquals - type: ResourceType = VALUE_LIST_BROWSE_DEFINITION; + browseType: ResourceType = VALUE_LIST_BROWSE_DEFINITION; get self(): string { return this._links.self.href; diff --git a/src/app/core/xsrf/xsrf.interceptor.spec.ts b/src/app/core/xsrf/xsrf.interceptor.spec.ts index 54afdc9b9c6..17a2a64b040 100644 --- a/src/app/core/xsrf/xsrf.interceptor.spec.ts +++ b/src/app/core/xsrf/xsrf.interceptor.spec.ts @@ -10,7 +10,9 @@ import { provideHttpClientTesting, } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { environment } from 'src/environments/environment.test'; +import { APP_CONFIG } from '../../../config/app-config.interface'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; import { RequestError } from '../data/request-error.model'; @@ -55,6 +57,7 @@ describe(`XsrfInterceptor`, () => { { provide: CookieService, useValue: cookieService }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/init.service.spec.ts b/src/app/init.service.spec.ts index 1e22ae2113e..52c821b89c9 100644 --- a/src/app/init.service.spec.ts +++ b/src/app/init.service.spec.ts @@ -48,8 +48,10 @@ import createSpyObj = jasmine.createSpyObj; import SpyObj = jasmine.SpyObj; import { getTestScheduler } from 'jasmine-marbles'; +import { HrefOnlyDataService } from './core/data/href-only-data.service'; import { HeadTagService } from './core/metadata/head-tag.service'; import { HeadTagServiceMock } from './shared/mocks/head-tag-service.mock'; +import { getMockHrefOnlyDataService } from './shared/mocks/href-only-data.service.mock'; let spy: SpyObj; @@ -197,6 +199,7 @@ describe('InitService', () => { { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, { provide: MenuService, useValue: menuServiceSpy }, { provide: ThemeService, useValue: getMockThemeService() }, + { provide: HrefOnlyDataService, useValue: getMockHrefOnlyDataService() }, provideMockStore({ initialState }), AppComponent, RouteService, diff --git a/src/app/init.service.ts b/src/app/init.service.ts index 789aa2e03d4..0fe5872f7c8 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -20,10 +20,16 @@ import { } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import isEqual from 'lodash/isEqual'; -import { Observable } from 'rxjs'; +import { + combineLatest, + Observable, + of, +} from 'rxjs'; import { distinctUntilChanged, find, + map, + take, } from 'rxjs/operators'; import { @@ -36,10 +42,12 @@ import { AppState } from './app.reducer'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { isAuthenticationBlocking } from './core/auth/selectors'; +import { HrefOnlyDataService } from './core/data/href-only-data.service'; import { LAZY_DATA_SERVICES } from './core/data-services-map'; import { LocaleService } from './core/locale/locale.service'; import { HeadTagService } from './core/metadata/head-tag.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service'; +import { hasValue } from './shared/empty.util'; import { dsDynamicFormControlMapFn } from './shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn'; import { MenuService } from './shared/menu/menu.service'; import { ThemeService } from './shared/theme-support/theme.service'; @@ -74,7 +82,7 @@ export abstract class InitService { protected breadcrumbsService: BreadcrumbsService, protected themeService: ThemeService, protected menuService: MenuService, - + protected hrefOnlyDataService: HrefOnlyDataService, ) { } @@ -230,4 +238,24 @@ export abstract class InitService { find((b: boolean) => b === false), ); } + + /** + * Use the bootstrapped requests to prefill the cache + */ + protected initBootstrapEndpoints$(): Observable { + if (hasValue(this.appConfig?.prefetch?.bootstrap)) { + const observables = {}; + + for (const url of Object.getOwnPropertyNames(this.appConfig.prefetch.bootstrap)) { + observables[url] = this.hrefOnlyDataService.findByHref(url, false); + } + + return combineLatest(observables).pipe( + take(1), + map(_ => undefined), + ); + } else { + return of(undefined); + } + } } diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index f470b09341d..28ce868d9c0 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -23,6 +23,7 @@ import { import { APP_CONFIG } from '../../../../../config/app-config.interface'; import { environment } from '../../../../../environments/environment.test'; import { REQUEST } from '../../../../../express.tokens'; +import { HashedFileMapping } from '../../../../../modules/dynamic-hash/hashed-file-mapping'; import { AuthRequestService } from '../../../../core/auth/auth-request.service'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; @@ -110,6 +111,10 @@ describe('EditRelationshipListComponent', () => { }, }; + const mockHashedFileMapping = { + resolve: (path: string) => path, + }; + function init(leftType: string, rightType: string, leftMaxCardinality?: number, rightMaxCardinality?: number): void { entityTypeLeft = Object.assign(new ItemType(), { id: leftType, @@ -273,6 +278,7 @@ describe('EditRelationshipListComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: REQUEST, useValue: {} }, CookieService, + { provide: HashedFileMapping, useValue: mockHashedFileMapping }, ], schemas: [ NO_ERRORS_SCHEMA, ], diff --git a/src/app/login-page/login-page.component.spec.ts b/src/app/login-page/login-page.component.spec.ts index 6cb4098c4de..e65b230c103 100644 --- a/src/app/login-page/login-page.component.spec.ts +++ b/src/app/login-page/login-page.component.spec.ts @@ -5,12 +5,12 @@ import { waitForAsync, } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { Store } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { APP_DATA_SERVICES_MAP } from '../../config/app-config.interface'; +import { HashedFileMapping } from '../../modules/dynamic-hash/hashed-file-mapping'; import { AuthService } from '../core/auth/auth.service'; import { XSRFService } from '../core/xsrf/xsrf.service'; import { AuthServiceMock } from '../shared/mocks/auth.service.mock'; @@ -24,12 +24,9 @@ describe('LoginPageComponent', () => { params: observableOf({}), }); - const store: Store = jasmine.createSpyObj('store', { - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - dispatch: {}, - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - select: observableOf(true), - }); + const mockHashedFileMapping = { + resolve: (path: string) => path, + }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -43,6 +40,7 @@ describe('LoginPageComponent', () => { { provide: XSRFService, useValue: {} }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, provideMockStore({}), + { provide: HashedFileMapping, useValue: mockHashedFileMapping }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index 539de03b09b..18292913665 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -20,6 +20,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; import { APP_DATA_SERVICES_MAP } from '../../../config/app-config.interface'; +import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { AppState } from '../../app.reducer'; import { authReducer, @@ -51,6 +52,10 @@ describe('AuthNavMenuComponent', () => { url: '/home', }; + const mockHashedFileMapping = { + resolve: (path: string) => path, + }; + function serviceInit() { authService = jasmine.createSpyObj('authService', { getAuthenticatedUserFromStore: of(EPersonMock), @@ -104,6 +109,7 @@ describe('AuthNavMenuComponent', () => { { provide: AuthService, useValue: authService }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, { provide: XSRFService, useValue: {} }, + { provide: HashedFileMapping, useValue: mockHashedFileMapping }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA, diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts index f0758ce1c24..75a550e4b9f 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts @@ -22,6 +22,7 @@ import { cold } from 'jasmine-marbles'; import { of } from 'rxjs'; import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; +import { HashedFileMapping } from '../../../../modules/dynamic-hash/hashed-file-mapping'; import { AppState } from '../../../app.reducer'; import { authReducer, @@ -44,6 +45,10 @@ describe('UserMenuComponent', () => { let authStateLoading: AuthState; let authService: AuthService; + const mockHashedFileMapping = { + resolve: (path: string) => path, + }; + function serviceInit() { authService = jasmine.createSpyObj('authService', { getAuthenticatedUserFromStore: of(EPersonMock), @@ -94,6 +99,7 @@ describe('UserMenuComponent', () => { { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, { provide: XSRFService, useValue: {} }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: HashedFileMapping, useValue: mockHashedFileMapping }, ], schemas: [ NO_ERRORS_SCHEMA, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts index 26606752117..70bc04e6b97 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts @@ -23,6 +23,7 @@ import { } from 'rxjs'; import { APP_DATA_SERVICES_MAP } from '../../../../../../config/app-config.interface'; +import { HashedFileMapping } from '../../../../../../modules/dynamic-hash/hashed-file-mapping'; import { RemoteDataBuildService } from '../../../../../core/cache/builders/remote-data-build.service'; import { ExternalSourceDataService } from '../../../../../core/data/external-source-data.service'; import { LookupRelationService } from '../../../../../core/data/lookup-relation.service'; @@ -84,6 +85,9 @@ describe('DsDynamicLookupRelationModalComponent', () => { const totalExternal = 8; const collection: Collection = new Collection(); + const mockHashedFileMapping = { + resolve: (path: string) => path, + }; function init() { item = Object.assign(new Item(), { uuid: '7680ca97-e2bd-4398-bfa7-139a8673dc42', metadata: {} }); @@ -152,6 +156,7 @@ describe('DsDynamicLookupRelationModalComponent', () => { { provide: APP_DATA_SERVICES_MAP, useValue: {} }, NgbActiveModal, provideMockStore(), + { provide: HashedFileMapping, useValue: mockHashedFileMapping }, ], schemas: [NO_ERRORS_SCHEMA], }) diff --git a/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.spec.ts b/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.spec.ts index 84664b7b20d..fbe37c9e0c6 100644 --- a/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.spec.ts +++ b/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.spec.ts @@ -4,6 +4,7 @@ import { } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; +import { environment } from '../../../../environments/environment.test'; import { RestRequestMethod } from '../../../core/data/rest-request-method'; import { EndpointMockingRestService } from './endpoint-mocking-rest.service'; import { ResponseMapMock } from './mocks/response-map.mock'; @@ -31,7 +32,7 @@ describe('EndpointMockingRestService', () => { request: observableOf(serverHttpResponse), }); - service = new EndpointMockingRestService(mockResponseMap, httpStub); + service = new EndpointMockingRestService(mockResponseMap, httpStub, environment); }); describe('get', () => { diff --git a/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.ts b/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.ts index bfdbe4d6d7d..ccaa578a4c3 100644 --- a/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.ts +++ b/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.ts @@ -11,6 +11,10 @@ import { of as observableOf, } from 'rxjs'; +import { + APP_CONFIG, + AppConfig, +} from '../../../../config/app-config.interface'; import { environment } from '../../../../environments/environment'; import { RestRequestMethod } from '../../../core/data/rest-request-method'; import { @@ -37,8 +41,9 @@ export class EndpointMockingRestService extends DspaceRestService { constructor( @Inject(MOCK_RESPONSE_MAP) protected mockResponseMap: ResponseMapMock, protected http: HttpClient, + @Inject(APP_CONFIG) protected appConfig: AppConfig, ) { - super(http); + super(http, appConfig); } /** diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts index bfec6ebeac7..305669d3629 100644 --- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts @@ -14,6 +14,7 @@ import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; +import { HashedFileMapping } from '../../../../../modules/dynamic-hash/hashed-file-mapping'; import { AuthService } from '../../../../core/auth/auth.service'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; @@ -71,6 +72,10 @@ const linkService = jasmine.createSpyObj('linkService', { resolveLink: mockCollectionWithAbstract, }); +const mockHashedFileMapping = { + resolve: (path: string) => path, +}; + describe('CollectionSearchResultGridElementComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -99,6 +104,7 @@ describe('CollectionSearchResultGridElementComponent', () => { { provide: XSRFService, useValue: {} }, { provide: LinkService, useValue: linkService }, provideMockStore({}), + { provide: HashedFileMapping, useValue: mockHashedFileMapping }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(CollectionSearchResultGridElementComponent, { diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index c0129327054..966a047f166 100644 --- a/src/app/shared/theme-support/theme.service.spec.ts +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -13,6 +13,7 @@ import { provideMockStore } from '@ngrx/store/testing'; import { hot } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; +import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { LinkService } from '../../core/cache/builders/link.service'; import { ConfigurationDataService } from '../../core/data/configuration-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; @@ -56,6 +57,10 @@ class MockLinkService { } } +const mockHashedFileMapping = { + resolve: (path: string) => path, +}; + describe('ThemeService', () => { let themeService: ThemeService; let linkService: LinkService; @@ -109,6 +114,10 @@ describe('ThemeService', () => { { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, { provide: ConfigurationDataService, useValue: configurationService }, + { + provide: HashedFileMapping, + useValue: mockHashedFileMapping, + }, ], }); @@ -422,6 +431,10 @@ describe('ThemeService', () => { { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, { provide: ConfigurationDataService, useValue: configurationService }, + { + 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 6d0ba38b5b7..a74965006e1 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -39,6 +39,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'; @@ -103,6 +104,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)); @@ -228,7 +230,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/config/app-config.interface.ts b/src/config/app-config.interface.ts index 7f5f0199582..9f840834073 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -26,6 +26,7 @@ import { LangConfig } from './lang-config.interface'; import { MarkdownConfig } from './markdown-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; +import { PrefetchConfig } from './prefetch-config'; import { QualityAssuranceConfig } from './quality-assurance.config'; import { SearchConfig } from './search-page-config.interface'; import { ServerConfig } from './server-config.interface'; @@ -34,7 +35,6 @@ import { SuggestionConfig } from './suggestion-config.interfaces'; import { ThemeConfig } from './theme.config'; import { UIServerConfig } from './ui-server-config.interface'; - interface AppConfig extends Config { ui: UIServerConfig; rest: ServerConfig; @@ -66,6 +66,7 @@ interface AppConfig extends Config { search: SearchConfig; notifyMetrics: AdminNotifyMetricsRow[]; liveRegion: LiveRegionConfig; + prefetch: PrefetchConfig; } /** diff --git a/src/config/config.server.ts b/src/config/config.server.ts index 3cbce3a42bc..e5015d97528 100644 --- a/src/config/config.server.ts +++ b/src/config/config.server.ts @@ -12,10 +12,15 @@ import { import { load } from 'js-yaml'; import { join } from 'path'; +import { RawBootstrapResponse } from '../app/core/dspace-rest/raw-rest-response.model'; import { isNotEmpty } from '../app/shared/empty.util'; +import { ServerHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.server'; import { AppConfig } from './app-config.interface'; import { Config } from './config.interface'; -import { mergeConfig } from './config.util'; +import { + extendEnvironmentWithAppConfig, + mergeConfig, +} from './config.util'; import { DefaultAppConfig } from './default-app-config'; import { ServerConfig } from './server-config.interface'; @@ -168,6 +173,7 @@ const buildBaseUrl = (config: ServerConfig): void => { ].join(''); }; + /** * Build app config with the following chain of override. * @@ -178,7 +184,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(); @@ -246,10 +252,63 @@ 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, true); + } console.log(`Angular ${bold('config.json')} file generated correctly at ${bold(destConfigPath)} \n`); } return appConfig; }; + +export const setupEndpointPrefetching = async (appConfig: AppConfig, destConfigPath: string, env: any, hfm: ServerHashedFileMapping): Promise => { + await prefetchResponses(appConfig, destConfigPath, env, hfm); + + return setInterval(() => void prefetchResponses(appConfig, destConfigPath, env, hfm), appConfig.prefetch.refreshInterval); +}; + +export const prefetchResponses = async (appConfig: AppConfig, destConfigPath: string, env: any, hfm: ServerHashedFileMapping): Promise => { + console.info('Prefetching REST responses'); + const restConfig = appConfig.rest; + const prefetchConfig = appConfig.prefetch; + + const baseUrl = restConfig.baseUrl; + const mapping: Record = {}; + + for (const relativeUrl of prefetchConfig.urls) { + const url = baseUrl + relativeUrl; + + let response: Response; + try { + response = await fetch(url); + } catch (e) { + console.warn(`Failed to prefetch REST response for url "${url}". Aborting prefetching. Is the REST server offline?`); + return; + } + + const headers: Record = {}; + response.headers.forEach((value, header) => { + headers[header] = value; + }); + + const rawBootstrapResponse: RawBootstrapResponse = { + payload: await response.json(), + headers: headers, + statusCode: response.status, + statusText: response.statusText, + }; + + mapping[url] = rawBootstrapResponse; + } + + prefetchConfig.bootstrap = mapping; + + const content = JSON.stringify(appConfig, null, 2); + extendEnvironmentWithAppConfig(env, appConfig, false); + hfm.add(destConfigPath, content, true); + hfm.save(); +}; diff --git a/src/config/config.util.ts b/src/config/config.util.ts index 005606e70ff..59698e2e8c2 100644 --- a/src/config/config.util.ts +++ b/src/config/config.util.ts @@ -12,12 +12,15 @@ import { /** * Extend Angular environment with app config. * - * @param env environment object - * @param appConfig app config + * @param env environment object + * @param appConfig app config + * @param logToConsole whether to log a message in the console */ -const extendEnvironmentWithAppConfig = (env: any, appConfig: AppConfig): void => { +const extendEnvironmentWithAppConfig = (env: any, appConfig: AppConfig, logToConsole = true): void => { mergeConfig(env, appConfig); - console.log(`Environment extended with app config`); + if (logToConsole) { + console.log(`Environment extended with app config`); + } }; /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 3c5e0ef0dac..5f651ad3bc7 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -21,6 +21,7 @@ import { LangConfig } from './lang-config.interface'; import { MarkdownConfig } from './markdown-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; +import { PrefetchConfig } from './prefetch-config'; import { QualityAssuranceConfig } from './quality-assurance.config'; import { SearchConfig } from './search-page-config.interface'; import { ServerConfig } from './server-config.interface'; @@ -599,4 +600,11 @@ export class DefaultAppConfig implements AppConfig { messageTimeOutDurationMs: 30000, isVisible: false, }; + + // EndpointMap prefetching configuration + prefetch: PrefetchConfig = { + urls: [], + bootstrap: {}, + refreshInterval: 60000, // refresh every minute + }; } diff --git a/src/config/prefetch-config.ts b/src/config/prefetch-config.ts new file mode 100644 index 00000000000..4f7aad7172a --- /dev/null +++ b/src/config/prefetch-config.ts @@ -0,0 +1,18 @@ +import { RawBootstrapResponse } from '../app/core/dspace-rest/raw-rest-response.model'; +import { Config } from './config.interface'; + +export interface PrefetchConfig extends Config { + /** + * The URLs that should be pre-fetched + */ + urls: string[]; + /** + * A mapping of URL to response + * bootstrap values should not be set manually, they will be overwritten. + */ + bootstrap: Record; + /** + * How often the responses should be refreshed in milliseconds + */ + refreshInterval: number; +} diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 40c5e8bc64b..35ed7a75717 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -432,4 +432,10 @@ export const environment: BuildConfig = { messageTimeOutDurationMs: 30000, isVisible: false, }, + + prefetch: { + urls: [], + bootstrap: {}, + refreshInterval: 60000, + }, }; diff --git a/src/main.browser.ts b/src/main.browser.ts index 324bf8d9e7c..67c3ff53101 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -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); @@ -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/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index 3bbdf12b914..ed024b7fd4a 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -32,6 +32,7 @@ import { AppState } from '../../app/app.reducer'; import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; import { AuthService } from '../../app/core/auth/auth.service'; import { coreSelector } from '../../app/core/core.selectors'; +import { HrefOnlyDataService } from '../../app/core/data/href-only-data.service'; import { RequestService } from '../../app/core/data/request.service'; import { RootDataService } from '../../app/core/data/root-data.service'; import { LocaleService } from '../../app/core/locale/locale.service'; @@ -40,7 +41,10 @@ import { HALEndpointService } from '../../app/core/shared/hal-endpoint.service'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { InitService } from '../../app/init.service'; import { OrejimeService } from '../../app/shared/cookies/orejime.service'; -import { isNotEmpty } from '../../app/shared/empty.util'; +import { + hasValue, + isNotEmpty, +} from '../../app/shared/empty.util'; import { MenuService } from '../../app/shared/menu/menu.service'; import { ThemeService } from '../../app/shared/theme-support/theme.service'; import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; @@ -86,7 +90,7 @@ export class BrowserInitService extends InitService { protected router: Router, private requestService: RequestService, private halService: HALEndpointService, - + protected hrefOnlyDataService: HrefOnlyDataService, ) { super( store, @@ -99,6 +103,7 @@ export class BrowserInitService extends InitService { breadcrumbsService, themeService, menuService, + hrefOnlyDataService, ); } @@ -131,6 +136,13 @@ export class BrowserInitService extends InitService { this.initOrejime(); + this.initBootstrapEndpoints$().subscribe(_ => { + if (hasValue(this.appConfig?.prefetch?.bootstrap)) { + // Clear bootstrap once finished so the dspace-rest.service does not keep using the bootstrapped responses + this.appConfig.prefetch.bootstrap = undefined; + } + }); + await lastValueFrom(this.authenticationReady$()); return true; diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index 60c6cbe4139..d62cc7dd441 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -17,6 +17,7 @@ import { take } from 'rxjs/operators'; import { AppState } from '../../app/app.reducer'; import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { HrefOnlyDataService } from '../../app/core/data/href-only-data.service'; import { LocaleService } from '../../app/core/locale/locale.service'; import { HeadTagService } from '../../app/core/metadata/head-tag.service'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; @@ -53,6 +54,7 @@ export class ServerInitService extends InitService { protected breadcrumbsService: BreadcrumbsService, protected themeService: ThemeService, protected menuService: MenuService, + protected hrefOnlyDataService: HrefOnlyDataService, ) { super( store, @@ -65,6 +67,7 @@ export class ServerInitService extends InitService { breadcrumbsService, themeService, menuService, + hrefOnlyDataService, ); } @@ -81,6 +84,8 @@ export class ServerInitService extends InitService { this.initRouteListeners(); this.themeService.listenForThemeChanges(false); + this.initBootstrapEndpoints$().subscribe(); + await lastValueFrom(this.authenticationReady$()); return true; 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..804f6c2e448 --- /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 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)); + } + } + } +} 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..995c79ed1af --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -0,0 +1,139 @@ +/** + * 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 { + copyFileSync, + existsSync, + 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): string { + if (content === undefined) { + content = readFileSync(path).toString(); + } + + // 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); + } + }); + } + + 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); + } + } + + /** + * 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.querySelector(`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; + } +}