diff --git a/config/config.example.yml b/config/config.example.yml index 0764edd0804..42209487eb1 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -444,3 +444,13 @@ search: # Since we don't know how many filters will be loaded before we receive a response from the server we use this parameter for the skeletons count. # e.g. If we set 5 then 5 loading skeletons will be visualized before the actual filters are retrieved. defaultFiltersCount: 5 + + +# 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.json b/package.json index 1ab4e54262f..682e9badfcf 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "ngx-skeleton-loader": "^7.0.0", "ngx-sortablejs": "^11.1.0", "ngx-ui-switch": "^14.1.0", + "node-html-parser": "^7.0.1", "nouislider": "^15.8.1", "pem": "1.14.8", "reflect-metadata": "^0.2.2", diff --git a/server.ts b/server.ts index cfab230ef59..3e390461f65 100644 --- a/server.ts +++ b/server.ts @@ -50,9 +50,10 @@ import { UIServerConfig } from './src/config/ui-server-config.interface'; import { ServerAppModule } 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'; @@ -68,7 +69,11 @@ const indexHtml = join(DIST_FOLDER, 'index.html'); const cookieParser = require('cookie-parser'); -const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); +const 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; @@ -261,7 +266,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, @@ -308,7 +313,7 @@ function serverSideRender(req, res, sendToUser: boolean = true) { * @param res current response */ function clientSideRender(req, res) { - res.sendFile(indexHtml); + res.sendFile(hashedFileMapping.resolve(indexHtml)); } @@ -535,7 +540,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 @@ -546,18 +551,21 @@ function createHttpsServer(keys) { // Graceful shutdown when signalled const terminator = createHttpTerminator({server: listener}); process.on('SIGINT', () => { - void (async ()=> { - console.debug('Closing HTTPS server on signal'); - await terminator.terminate().catch(e => { console.error(e); }); - console.debug('HTTPS server closed'); - })(); + void (async () => { + console.debug('Closing HTTPS server on signal'); + clearTimeout(prefetchRefreshTimeout); + await terminator.terminate().catch(e => { + console.error(e); }); + console.debug('HTTPS server closed'); + })(); + }); } /** * 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 || '/'; @@ -570,15 +578,18 @@ function run() { // Graceful shutdown when signalled const terminator = createHttpTerminator({server: listener}); 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; - })(); + void (async () => { + console.debug('Closing HTTP server on signal'); + clearTimeout(prefetchRefreshTimeout); + await terminator.terminate().catch(e => { + console.error(e); }); + console.debug('HTTP server closed.'); + })(); + }); } -function start() { +function start(prefetchRefreshTimeout: NodeJS.Timeout) { logStartupMessage(environment); /* @@ -604,10 +615,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.'); @@ -617,11 +629,11 @@ function start() { days: 1, selfSigned: true }, (error, keys) => { - createHttpsServer(keys); + createHttpsServer(prefetchRefreshTimeout, keys); }); } } else { - run(); + run(prefetchRefreshTimeout); } } @@ -646,8 +658,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/app.module.ts b/src/app/app.module.ts index 1aa5036bce8..7184f77c991 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'; @@ -110,6 +112,10 @@ const PROVIDERS = [ 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, ]; diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 04bbc4acaf0..dc6998ef7d4 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -13,6 +13,8 @@ import { RouterStub } from '../../shared/testing/router.stub'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RestRequestMethod } from '../data/rest-request-method'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from 'src/environments/environment.test'; describe(`AuthInterceptor`, () => { let service: DspaceRestService; @@ -39,6 +41,7 @@ describe(`AuthInterceptor`, () => { multi: true, }, { provide: Store, useValue: store }, + { 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 bc495a51f4f..f8d02ffa912 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -6,56 +6,17 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list.model'; import { FindListOptions } from '../data/find-list-options.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { dataService } from '../data/base/data-service.decorator'; -import { isNotEmpty, isNotEmptyOperator, hasValue } from '../../shared/empty.util'; -import { take } from 'rxjs/operators'; -import { BrowseDefinitionRestRequest } from '../data/request.models'; import { RequestParam } from '../cache/models/request-param.model'; import { SearchData, SearchDataImpl } from '../data/base/search-data'; import { BrowseDefinition } from '../shared/browse-definition.model'; -/** - * 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 */ @@ -64,7 +25,7 @@ class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl }) @dataService(BROWSE_DEFINITION) export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { - private findAllData: BrowseDefinitionFindAllDataImpl; + private findAllData: FindAllData; private searchData: SearchDataImpl; constructor( @@ -75,7 +36,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 9e5ebaed854..69f4f10b124 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -27,15 +27,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 075bf3ca0ca..7fd8c5aaf0d 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -16,7 +16,7 @@ import { ObjectCacheService } from '../object-cache.service'; import { LinkService } from './link.service'; import { HALLink } from '../../shared/hal-link.model'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { getClassForType } from './build-decorators'; +import { getClassForObject } from './build-decorators'; import { HALResource } from '../../shared/hal-resource.model'; import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; @@ -90,7 +90,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 0330a03f02c..ec5ee6235fb 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -10,7 +10,7 @@ import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors'; import { GenericConstructor } from '../shared/generic-constructor'; -import { getClassForType } from './builders/build-decorators'; +import { getClassForObject } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; @@ -142,7 +142,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/core.module.ts b/src/app/core/core.module.ts index dd465093156..da36226b1e2 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -183,16 +183,18 @@ import { ValueListBrowseDefinition } from './shared/value-list-browse-definition import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; import { SubmissionParentBreadcrumbsService } from './submission/submission-parent-breadcrumb.service'; +import { APP_CONFIG, AppConfig } from '../../config/app-config.interface'; +import { StatisticsEndpoint } from '../statistics/statistics-endpoint.model'; /** * 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); } }; @@ -213,7 +215,7 @@ const PROVIDERS = [ SiteDataService, DSOResponseParsingService, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, + { provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient, APP_CONFIG] }, EPersonDataService, LinkHeadService, HALEndpointService, @@ -382,7 +384,8 @@ export const models = IdentifierData, Subscription, ItemRequest, - BulkAccessConditionOptions + BulkAccessConditionOptions, + StatisticsEndpoint, ]; @NgModule({ diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 18e6623683f..ff91ef411ce 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -6,7 +6,7 @@ import { PageInfo } from '../shared/page-info.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { getClassForObject } from '../cache/builders/build-decorators'; import { environment } from '../../../environments/environment'; import { CacheableObject } from '../cache/cacheable-object.model'; import { RestRequest } from './rest-request.model'; @@ -111,7 +111,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 3ffcd9adf20..85beb96255c 100644 --- a/src/app/core/data/base/create-data.ts +++ b/src/app/core/data/base/create-data.ts @@ -5,22 +5,30 @@ * * http://www.dspace.org/license/ */ +import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + map, + take, + takeWhile, +} from 'rxjs/operators'; +import { + hasValue, + isNotEmptyOperator, +} from '../../../shared/empty.util'; +import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getClassForObject } from '../../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; import { RequestParam } from '../../cache/models/request-param.model'; -import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; -import { distinctUntilChanged, map, take, takeWhile } from 'rxjs/operators'; +import { ObjectCacheService } from '../../cache/object-cache.service'; import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; -import { getClassForType } from '../../cache/builders/build-decorators'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; import { CreateRequest } from '../request.models'; -import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that can create objects. @@ -78,7 +86,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 9fa7239ef7c..a4b3919c851 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -4,6 +4,7 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; +import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; class TestService extends BrowseResponseParsingService { constructor(protected objectCache: ObjectCacheService) { @@ -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 70c45bbc2de..ecb8325eefa 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -1,10 +1,12 @@ import { Injectable } from '@angular/core'; -import { compare } from 'fast-json-patch'; -import { Operation } from 'fast-json-patch'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { + compare, + Operation, +} from 'fast-json-patch'; +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'; -import { TypedObject } from '../cache/typed-object.model'; /** * A class to determine what differs between two @@ -22,8 +24,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 c0e1c70cae9..b40b66ea491 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -1,25 +1,34 @@ /* eslint-disable max-classes-per-file */ -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { Serializer } from '../serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; +import { Injectable } from '@angular/core'; import { environment } from '../../../environments/environment'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +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'; +import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { + getEmbedSizeParams, + getUrlWithoutEmbedParams, +} from '../index/index.selectors'; +import { Serializer } from '../serializer'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { Injectable } from '@angular/core'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { PageInfo } from '../shared/page-info.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; import { ResponseParsingService } from './parsing.service'; -import { ParsedResponse } from '../cache/response.models'; import { RestRequestMethod } from './rest-request-method'; -import { getUrlWithoutEmbedParams, getEmbedSizeParams } from '../index/index.selectors'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { CacheableObject } from '../cache/cacheable-object.model'; import { RestRequest } from './rest-request.model'; - /** * Return true if obj has a value for `_links.self` * @@ -198,13 +207,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; } } @@ -213,15 +221,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 728714876c4..e2f2fb2de20 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -1,17 +1,17 @@ import { Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { hasValue } from '../../shared/empty.util'; +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'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { GenericConstructor } from '../shared/generic-constructor'; import { DspaceRestResponseParsingService, - isCacheableObject + isCacheableObject, } from './dspace-rest-response-parsing.service'; -import { hasValue } from '../../shared/empty.util'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { ParsedResponse } from '../cache/response.models'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { environment } from '../../../environments/environment'; -import { CacheableObject } from '../cache/cacheable-object.model'; import { RestRequest } from './rest-request.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 d79dd9a2835..ebb138df449 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -1,12 +1,18 @@ -import { Injectable, Injector } from '@angular/core'; +import { + Injectable, + Injector, +} from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { catchError, filter, map, mergeMap, take, withLatestFrom } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; import { StoreActionTypes } from '../../store.actions'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { getClassForObject } from '../cache/builders/build-decorators'; +import { ParsedResponse } from '../cache/response.models'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { XSRFService } from '../xsrf/xsrf.service'; @@ -15,14 +21,14 @@ import { RequestErrorAction, RequestExecuteAction, RequestSuccessAction, - ResetResponseTimestampsAction + ResetResponseTimestampsAction, } from './request.actions'; import { RequestService } from './request.service'; -import { ParsedResponse } from '../cache/response.models'; import { RequestError } from './request-error.model'; import { RestRequestMethod } from './rest-request-method'; import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model'; import { RequestEntry } from './request-entry.model'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; @Injectable() export class RequestEffects { @@ -43,7 +49,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 2188522dc0c..e9437086d09 100644 --- a/src/app/core/dspace-rest/dspace-rest.service.spec.ts +++ b/src/app/core/dspace-rest/dspace-rest.service.spec.ts @@ -5,6 +5,8 @@ import { DEFAULT_CONTENT_TYPE, DspaceRestService } from './dspace-rest.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { RestRequestMethod } from '../data/rest-request-method'; import { HttpHeaders } from '@angular/common/http'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; describe('DspaceRestService', () => { let dspaceRestService: DspaceRestService; @@ -19,7 +21,10 @@ describe('DspaceRestService', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [DspaceRestService] + providers: [ + DspaceRestService, + { provide: APP_CONFIG, useValue: environment }, + ], }); dspaceRestService = TestBed.inject(DspaceRestService); diff --git a/src/app/core/dspace-rest/dspace-rest.service.ts b/src/app/core/dspace-rest/dspace-rest.service.ts index ea4e8c28310..84733c6de6b 100644 --- a/src/app/core/dspace-rest/dspace-rest.service.ts +++ b/src/app/core/dspace-rest/dspace-rest.service.ts @@ -1,12 +1,13 @@ -import { Observable, throwError as observableThrowError } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; +import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { Injectable, Inject } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http'; -import { RawRestResponse } from './raw-rest-response.model'; +import { RawRestResponse, rawBootstrapToRawRestResponse } from './raw-rest-response.model'; import { RestRequestMethod } from '../data/rest-request-method'; import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; export const DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8'; export interface HttpOptions { @@ -25,8 +26,10 @@ export interface HttpOptions { @Injectable() export class DspaceRestService { - constructor(protected http: HttpClient) { - + constructor( + protected http: HttpClient, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { } /** @@ -104,6 +107,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, @@ -145,4 +160,20 @@ export class DspaceRestService { return form; } + /** + * 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 bd9e4f90764..e6525e51e11 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 @@ -4,6 +4,8 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { REQUEST } from '@nguniversal/express-engine/tokens'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from 'src/environments/environment.test'; describe('ForwardClientIpInterceptor', () => { let service: DspaceRestService; @@ -25,7 +27,8 @@ describe('ForwardClientIpInterceptor', () => { useClass: ForwardClientIpInterceptor, multi: true, }, - { provide: REQUEST, useValue: { get: () => undefined, connection: { remoteAddress: clientIp } } } + { provide: REQUEST, useValue: { get: () => undefined, connection: { remoteAddress: clientIp } } }, + { 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 e96126d19c8..13bf2c56f76 100644 --- a/src/app/core/locale/locale.interceptor.spec.ts +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -7,6 +7,8 @@ import { RestRequestMethod } from '../data/rest-request-method'; import { LocaleService } from './locale.service'; import { LocaleInterceptor } from './locale.interceptor'; import { of } from 'rxjs'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; describe(`LocaleInterceptor`, () => { let service: DspaceRestService; @@ -31,6 +33,7 @@ describe(`LocaleInterceptor`, () => { multi: true, }, { provide: LocaleService, useValue: mockLocaleService }, + { 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 cae9c322029..29f51de196e 100644 --- a/src/app/core/log/log.interceptor.spec.ts +++ b/src/app/core/log/log.interceptor.spec.ts @@ -13,6 +13,8 @@ import { CorrelationIdService } from '../../correlation-id/correlation-id.servic import { UUIDService } from '../shared/uuid.service'; import { StoreModule } from '@ngrx/store'; import { appReducers, storeModuleConfig } from '../../app.reducer'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; describe('LogInterceptor', () => { @@ -49,6 +51,7 @@ describe('LogInterceptor', () => { { provide: Router, useValue: router }, { provide: CorrelationIdService, useClass: CorrelationIdService }, { provide: UUIDService, useClass: UUIDService }, + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index bd7e4862cea..d26e6144a55 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -3,11 +3,18 @@ import { autoserializeAs, } from 'cerialize'; 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 086fca891bb..09afaeab32f 100644 --- a/src/app/core/shared/flat-browse-definition.model.ts +++ b/src/app/core/shared/flat-browse-definition.model.ts @@ -1,5 +1,5 @@ import { inheritSerialization, deserialize } from 'cerialize'; -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 { ResourceType } from './resource-type'; @@ -9,16 +9,16 @@ import { HALLink } from './hal-link.model'; /** * 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 b64ecabdf2d..ed84b6ead15 100644 --- a/src/app/core/shared/hierarchical-browse-definition.model.ts +++ b/src/app/core/shared/hierarchical-browse-definition.model.ts @@ -1,5 +1,7 @@ import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; +import { + typedObjectWithSubType, +} from '../cache/builders/build-decorators'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { HIERARCHICAL_BROWSE_DEFINITION } from './hierarchical-browse-definition.resource-type'; import { HALLink } from './hal-link.model'; @@ -9,16 +11,16 @@ import { BrowseDefinition } from './browse-definition.model'; /** * 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 3378263962e..6726c8044f6 100644 --- a/src/app/core/shared/value-list-browse-definition.model.ts +++ b/src/app/core/shared/value-list-browse-definition.model.ts @@ -1,5 +1,5 @@ import { inheritSerialization, deserialize } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; +import { typedObjectWithSubType } from '../cache/builders/build-decorators'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { VALUE_LIST_BROWSE_DEFINITION } from './value-list-browse-definition.resource-type'; import { ResourceType } from './resource-type'; @@ -9,16 +9,16 @@ import { HALLink } from './hal-link.model'; /** * 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 4a78b60fc17..9fd5215ffe9 100644 --- a/src/app/core/xsrf/xsrf.interceptor.spec.ts +++ b/src/app/core/xsrf/xsrf.interceptor.spec.ts @@ -7,6 +7,8 @@ import { CookieService } from '../services/cookie.service'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { XsrfInterceptor } from './xsrf.interceptor'; import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from 'src/environments/environment.test'; describe(`XsrfInterceptor`, () => { let service: DspaceRestService; @@ -35,7 +37,8 @@ describe(`XsrfInterceptor`, () => { multi: true, }, { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock(testToken) }, - { provide: CookieService, useValue: new CookieServiceMock() } + { provide: CookieService, useValue: new CookieServiceMock() }, + { provide: APP_CONFIG, useValue: environment }, ], }); diff --git a/src/app/init.service.spec.ts b/src/app/init.service.spec.ts index a05f06c78fa..59cfe2a5098 100644 --- a/src/app/init.service.spec.ts +++ b/src/app/init.service.spec.ts @@ -31,6 +31,8 @@ import objectContaining = jasmine.objectContaining; import createSpyObj = jasmine.createSpyObj; import SpyObj = jasmine.SpyObj; import { getTestScheduler } from 'jasmine-marbles'; +import { HrefOnlyDataService } from './core/data/href-only-data.service'; +import { getMockHrefOnlyDataService } from './shared/mocks/href-only-data.service.mock'; let spy: SpyObj; @@ -180,6 +182,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 9fef2ca4edd..6ed33e1cffd 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -21,9 +21,11 @@ import { MetadataService } from './core/metadata/metadata.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { ThemeService } from './shared/theme-support/theme.service'; import { isAuthenticationBlocking } from './core/auth/selectors'; -import { distinctUntilChanged, find } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { distinctUntilChanged, find, map, take } from 'rxjs/operators'; +import { combineLatest, Observable, of } from 'rxjs'; import { MenuService } from './shared/menu/menu.service'; +import { hasValue } from './shared/empty.util'; +import { HrefOnlyDataService } from './core/data/href-only-data.service'; /** * Performs the initialization of the app. @@ -53,7 +55,7 @@ export abstract class InitService { protected breadcrumbsService: BreadcrumbsService, protected themeService: ThemeService, protected menuService: MenuService, - + protected hrefOnlyDataService: HrefOnlyDataService, ) { } @@ -201,4 +203,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 (let 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/shared/mocks/dspace-rest/endpoint-mocking-rest.service.spec.ts b/src/app/shared/mocks/dspace-rest/endpoint-mocking-rest.service.spec.ts index 0b6b218164b..777b734d215 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 @@ -3,6 +3,7 @@ import { of as observableOf } from 'rxjs'; import { RestRequestMethod } from '../../../core/data/rest-request-method'; import { EndpointMockingRestService } from './endpoint-mocking-rest.service'; import { ResponseMapMock } from './mocks/response-map.mock'; +import { environment } from '../../../../environments/environment.test'; describe('EndpointMockingRestService', () => { let service: EndpointMockingRestService; @@ -27,7 +28,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 0358451557a..4fefc282c07 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 @@ -8,6 +8,7 @@ import { RawRestResponse } from '../../../core/dspace-rest/raw-rest-response.mod import { DspaceRestService, HttpOptions } from '../../../core/dspace-rest/dspace-rest.service'; import { MOCK_RESPONSE_MAP, ResponseMapMock } from './mocks/response-map.mock'; import { environment } from '../../../../environments/environment'; +import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; /** * Service to access DSpace's REST API. @@ -21,9 +22,10 @@ export class EndpointMockingRestService extends DspaceRestService { constructor( @Inject(MOCK_RESPONSE_MAP) protected mockResponseMap: ResponseMapMock, - protected http: HttpClient + protected http: HttpClient, + @Inject(APP_CONFIG) protected appConfig: AppConfig, ) { - super(http); + super(http, appConfig); } /** diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index 34dffe2ef21..c72c22245e2 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'; @@ -48,6 +49,10 @@ class MockLinkService { } } +const mockHashedFileMapping = { + resolve: (path: string) => path, +}; + describe('ThemeService', () => { let themeService: ThemeService; let linkService: LinkService; @@ -101,6 +106,10 @@ describe('ThemeService', () => { { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, { provide: ConfigurationDataService, useValue: configurationService }, + { + provide: HashedFileMapping, + useValue: mockHashedFileMapping, + }, ] }); @@ -414,6 +423,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 40aa559b23d..b0da3fe9c40 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, concatMap } from 'rxjs'; +import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { ThemeState } from './theme.reducer'; import { SetThemeAction, ThemeActionTypes } from './theme.actions'; import { defaultIfEmpty, expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; @@ -54,6 +55,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)); @@ -179,7 +181,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/app/statistics/statistics.module.ts b/src/app/statistics/statistics.module.ts index 4870e4fbf0b..f4d515030c5 100644 --- a/src/app/statistics/statistics.module.ts +++ b/src/app/statistics/statistics.module.ts @@ -3,14 +3,6 @@ import { CommonModule } from '@angular/common'; import { CoreModule } from '../core/core.module'; import { SharedModule } from '../shared/shared.module'; import { ViewTrackerComponent } from './angulartics/dspace/view-tracker.component'; -import { StatisticsEndpoint } from './statistics-endpoint.model'; - -/** - * Declaration needed to make sure all decorator functions are called in time - */ -export const models = [ - StatisticsEndpoint -]; @NgModule({ imports: [ diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index af78d4ab887..d6bec804385 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -24,6 +24,7 @@ import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { DiscoverySortConfig } from './discovery-sort.config'; import { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; import { SearchConfig } from './search-page-config.interface'; +import { PrefetchConfig } from './prefetch-config'; interface AppConfig extends Config { ui: UIServerConfig; @@ -52,6 +53,7 @@ interface AppConfig extends Config { comcolSelectionSort: DiscoverySortConfig; liveRegion: LiveRegionConfig; search: SearchConfig + prefetch: PrefetchConfig; } /** diff --git a/src/config/config.server.ts b/src/config/config.server.ts index 488c1c7a4c6..537331b4e0b 100644 --- a/src/config/config.server.ts +++ b/src/config/config.server.ts @@ -2,13 +2,15 @@ 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'; import { DefaultAppConfig } from './default-app-config'; import { ServerConfig } from './server-config.interface'; -import { mergeConfig } from './config.util'; +import { mergeConfig, extendEnvironmentWithAppConfig } from './config.util'; import { isNotEmpty } from '../app/shared/empty.util'; +import { RawBootstrapResponse } from '../app/core/dspace-rest/raw-rest-response.model'; const CONFIG_PATH = join(process.cwd(), 'config'); @@ -159,6 +161,7 @@ const buildBaseUrl = (config: ServerConfig): void => { ].join(''); }; + /** * Build app config with the following chain of override. * @@ -169,7 +172,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(); @@ -237,10 +240,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 c1d87e34909..cf6a1cc5f39 100644 --- a/src/config/config.util.ts +++ b/src/config/config.util.ts @@ -11,12 +11,15 @@ import { BASE_THEME_NAME } from '../app/shared/theme-support/theme.constants'; /** * 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 de4f3bd56ec..49c309c51da 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -24,6 +24,7 @@ import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { DiscoverySortConfig } from './discovery-sort.config'; import { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; import { SearchConfig } from './search-page-config.interface'; +import { PrefetchConfig } from './prefetch-config'; export class DefaultAppConfig implements AppConfig { production = false; @@ -447,4 +448,11 @@ export class DefaultAppConfig implements AppConfig { search: SearchConfig = { filterPlaceholdersCount: 5 }; + + // 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..85015cd79ee --- /dev/null +++ b/src/config/prefetch-config.ts @@ -0,0 +1,18 @@ +import { Config } from './config.interface'; +import { RawBootstrapResponse } from '../app/core/dspace-rest/raw-rest-response.model'; + +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 30ca741b36d..1ecc2729d9e 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -328,5 +328,11 @@ export const environment: BuildConfig = { search: { filterPlaceholdersCount: 5 - } + }, + + prefetch: { + urls: [], + bootstrap: {}, + refreshInterval: 60000, + }, }; diff --git a/src/main.browser.ts b/src/main.browser.ts index 43b2ffbaf40..a73494cda2a 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(document); 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/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index 4b1c6b40d27..7689e0d47f1 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -27,7 +27,7 @@ import { ThemeService } from '../../app/shared/theme-support/theme.service'; import { StoreAction, StoreActionTypes } from '../../app/store.actions'; import { coreSelector } from '../../app/core/core.selectors'; import { filter, find, map } from 'rxjs/operators'; -import { isNotEmpty } from '../../app/shared/empty.util'; +import { isNotEmpty, hasValue } from '../../app/shared/empty.util'; import { logStartupMessage } from '../../../startup-message'; import { MenuService } from '../../app/shared/menu/menu.service'; import { RequestService } from '../../app/core/data/request.service'; @@ -36,6 +36,7 @@ import { firstValueFrom, lastValueFrom, Subscription } from 'rxjs'; import { ServerCheckGuard } from '../../app/core/server-check/server-check.guard'; import { HALEndpointService } from '../../app/core/shared/hal-endpoint.service'; import { BuildConfig } from '../../config/build-config.interface'; +import { HrefOnlyDataService } from '../../app/core/data/href-only-data.service'; /** * Performs client-side initialization. @@ -64,6 +65,7 @@ export class BrowserInitService extends InitService { protected serverCheckGuard: ServerCheckGuard, private requestService: RequestService, private halService: HALEndpointService, + protected hrefOnlyDataService: HrefOnlyDataService, ) { super( store, @@ -76,6 +78,7 @@ export class BrowserInitService extends InitService { breadcrumbsService, themeService, menuService, + hrefOnlyDataService, ); } @@ -108,6 +111,13 @@ export class BrowserInitService extends InitService { this.initKlaro(); + 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 this.authenticationReady$().toPromise(); return true; diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index e0c55211e35..4e027f8326e 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -23,6 +23,7 @@ import { take } from 'rxjs/operators'; import { MenuService } from '../../app/shared/menu/menu.service'; import { isEmpty, isNotEmpty } from '../../app/shared/empty.util'; import { BuildConfig } from '../../config/build-config.interface'; +import { HrefOnlyDataService } from '../../app/core/data/href-only-data.service'; /** * Performs server-side initialization. @@ -40,7 +41,8 @@ export class ServerInitService extends InitService { protected metadata: MetadataService, protected breadcrumbsService: BreadcrumbsService, protected themeService: ThemeService, - protected menuService: MenuService + protected menuService: MenuService, + protected hrefOnlyDataService: HrefOnlyDataService, ) { super( store, @@ -53,6 +55,7 @@ export class ServerInitService extends InitService { breadcrumbsService, themeService, menuService, + hrefOnlyDataService, ); } @@ -69,6 +72,8 @@ export class ServerInitService extends InitService { this.initRouteListeners(); this.themeService.listenForThemeChanges(false); + this.initBootstrapEndpoints$().subscribe(); + await this.authenticationReady$().toPromise(); 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..576dc386314 --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.browser.ts @@ -0,0 +1,45 @@ +/** + * 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..822b3f5864f --- /dev/null +++ b/src/modules/dynamic-hash/hashed-file-mapping.server.ts @@ -0,0 +1,138 @@ +/** + * 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, + copyFileSync, + existsSync, +} 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; + }, {}); + + let 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; + } +} diff --git a/yarn.lock b/yarn.lock index 7983caea239..1da8ef16285 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4395,6 +4395,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" @@ -4403,7 +4414,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== @@ -6352,6 +6363,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" @@ -8488,6 +8504,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"