Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d336d83
Cache-bust dynamic configuration files
ybnd Feb 13, 2025
fdc6e8a
Don't try to 'run' JSON on load
ybnd Feb 17, 2025
2f23599
Add theme CSS to hashed file map
ybnd Feb 17, 2025
02bbd7c
124369: Add PrefetchConfig
AAwouters Jan 16, 2025
4024840
124369: Prefetch requests and write to config
AAwouters Feb 19, 2025
66cdb69
124369: Separate request performing into method
AAwouters Jan 31, 2025
7abd9bb
124369: Retrieve prefetched response
AAwouters Jan 31, 2025
932bde7
124369: Fill request cache with prefetched requests in server & browser
AAwouters Feb 3, 2025
509ed7a
124369: Add StatisticsEndpoint model to models array of core.module
AAwouters Feb 4, 2025
93dbe13
124369: Fix tests by providing APP_CONFIG
AAwouters Feb 19, 2025
ba91fe5
124369: Retrieve 'browse' model constructors by their 'browseType'
AAwouters Feb 20, 2025
38ed248
Follow REST model for browe definitions more closely
ybnd Feb 24, 2025
f54baf7
Merge remote-tracking branch 'ybnd/cache-bust-dynamic-configuration-7…
ybnd Feb 24, 2025
168a967
Compress hashed config.json
ybnd Feb 24, 2025
79d4da1
Ensure bootstrapped requests are included in hashed config
ybnd Feb 24, 2025
13e039e
Fix browse response parsing test
AAwouters Feb 26, 2025
5ba59b6
124369: Update prefetch config comments
AAwouters Mar 12, 2025
d520511
124369: Fix prefetching info message
AAwouters Mar 12, 2025
edf94a1
Merge branch 'w2p-124369_Bootstrap-HAL-endpoint-maps' into prefetch-r…
AAwouters Mar 13, 2025
d1a8112
124369: Stop prefetching responses when process is terminated
AAwouters Mar 13, 2025
b353bf9
124369: Catch errors during fetching of responses
AAwouters Mar 14, 2025
b4adfba
Merge branch 'w2p-124369_Bootstrap-HAL-endpoint-maps' into prefetch-r…
AAwouters Mar 14, 2025
95b58d2
124369: Wait until responses have been used before clearing prefetche…
AAwouters Mar 18, 2025
8cb3fad
Merge branch 'w2p-124369_Bootstrap-HAL-endpoint-maps' into prefetch-r…
AAwouters Mar 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 42 additions & 26 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<string, any>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}


Expand Down Expand Up @@ -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
Expand All @@ -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 || '/';

Expand All @@ -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);

/*
Expand All @@ -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.');

Expand All @@ -617,11 +629,11 @@ function start() {
days: 1,
selfSigned: true
}, (error, keys) => {
createHttpsServer(keys);
createHttpsServer(prefetchRefreshTimeout, keys);
});
}
} else {
run();
run(prefetchRefreshTimeout);
}
}

Expand All @@ -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';
6 changes: 6 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
];
Expand Down
3 changes: 3 additions & 0 deletions src/app/core/auth/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,6 +41,7 @@ describe(`AuthInterceptor`, () => {
multi: true,
},
{ provide: Store, useValue: store },
{ provide: APP_CONFIG, useValue: environment },
],
});

Expand Down
49 changes: 3 additions & 46 deletions src/app/core/browse/browse-definition-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
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<BrowseDefinition> {
createAndSendGetRequest(href$: string | Observable<string>, useCachedVersionIfAvailable: boolean = true) {
createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable);
}
}

/**
* Data service responsible for retrieving browse definitions from the REST server
*/
Expand All @@ -64,7 +25,7 @@ class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl<BrowseDefinition>
})
@dataService(BROWSE_DEFINITION)
export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition>, SearchData<BrowseDefinition> {
private findAllData: BrowseDefinitionFindAllDataImpl;
private findAllData: FindAllData<BrowseDefinition>;
private searchData: SearchDataImpl<BrowseDefinition>;

constructor(
Expand All @@ -75,7 +36,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
) {
super('browses', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.findAllData = new BrowseDefinitionFindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}

/**
Expand Down Expand Up @@ -160,9 +121,5 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
...linksToFollow,
);
}

createAndSendGetRequest(href$: string | Observable<string>, useCachedVersionIfAvailable: boolean = true) {
createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable);
}
}

44 changes: 40 additions & 4 deletions src/app/core/cache/builders/build-decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/app/core/cache/builders/remote-data-build.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<T>(obj: any): T {
const type: GenericConstructor<T> = getClassForType(obj.type);
const type: GenericConstructor<T> = getClassForObject(obj);
if (typeof type === 'function') {
return Object.assign(new type(), obj) as T;
} else {
Expand Down
4 changes: 2 additions & 2 deletions src/app/core/cache/object-cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -142,7 +142,7 @@ export class ObjectCacheService {
),
map((entry: ObjectCacheEntry) => {
if (hasValue(entry.data)) {
const type: GenericConstructor<T> = getClassForType((entry.data as any).type);
const type: GenericConstructor<T> = getClassForObject(entry.data as any);
if (typeof type !== 'function') {
throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`);
}
Expand Down
Loading
Loading