The express-engine schematic makes adding server side rendering to an application relatively straightforward; although there are some caveats. One such caveat is in relation to the use of relative URLs in HttpClient requests made from the client side. These requests will fail during server side rendering - as absolute URLs are required during SSR. A HttpInterceptor to convert relative paths to absolute paths gets around this issue
Note: tested with @nguniversal/express-engine 7.1.1
Http Interceptor
The Angular Guide documents the relative URL issue, and describes an implementation of an interceptor that will work for most (basic) use cases. The example provided in the docs assumes that all XHR calls you make client side are using relavite URLs e.g. /api/users
, /assets/settings.json
, so any calls to external API's with an absolute URL will also be updated incorrectly by the interceptor, with additional https://
prepended
I created a more robust version of the interceptor, which checks if the URL is relative or absolute, and updates the URL if necessary
universal-relative.interceptor.ts
import { HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Inject, Injectable, Optional } from '@angular/core';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';
// case insensitive check against config and value
const startsWithAny = (arr: string[] = []) => (value = '') => {
return arr.some(test => value.toLowerCase().startsWith(test.toLowerCase()));
};
// http, https, protocol relative
const isAbsoluteURL = startsWithAny(['http', '//']);
@Injectable()
export class UniversalRelativeInterceptor implements HttpInterceptor {
constructor(@Optional() @Inject(REQUEST) protected request: Request) {}
intercept(req: HttpRequest<any>, next: HttpHandler) {
if (this.request && !isAbsoluteURL(req.url)) {
const protocolHost = `${this.request.protocol}://${this.request.get(
'host'
)}`;
const pathSeparator = !req.url.startsWith('/') ? '/' : '';
const url = protocolHost + pathSeparator + req.url;
const serverRequest = req.clone({ url });
return next.handle(serverRequest);
} else {
return next.handle(req);
}
}
}
The interceptor should be provided in the server module class
app.server.module.ts
@NgModule({
imports: [
AppModule,
ServerModule,
ServerTransferStateModule,
ModuleMapLoaderModule,
],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: UniversalRelativeInterceptor,
multi: true
}
],
bootstrap: [AppComponent],
})
export class AppServerModule {}
Unit Test
An interesting feature of this unit test is the use of inject
from angular/core/testing to inject the HttpClient
- which cannot be simply injected via providers
. Usually I find myself testing services that already have the HttpClient
injected, or using a mock service which injects the HttpClient
as a way of testing an interceptor. In this test we are going to use inject
, and pass in the HttpClient
as a dependency directly
import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';
import { UniversalRelativeInterceptor } from './universal-relative.interceptor';
const CONSTANTS = {
protocol: 'testProtocol',
host: 'testHost'
};
const configureTestModule = (request: Request) => {
TestBed.configureTestingModule({
imports: [HttpClientModule, HttpClientTestingModule],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: UniversalRelativeInterceptor,
multi: true
},
{
provide: REQUEST,
useValue: request
}
]
});
};
describe(`UniversalRelativeInterceptor`, () => {
describe('absolute and protocol-relative url', () => {
beforeEach(() => {
configureTestModule({} as Request);
});
// Using 'inject' as opposed to creating a mock service with a HttpClient
it('should not change absolute or protocol-relative url', inject(
[HttpClient],
(httpClient: HttpClient) => {
// The SSR Interceptor uses express.js Request, with token REQUEST
const httpMock = TestBed.get(
HttpTestingController
) as HttpTestingController;
const httpURL = 'http://test.com/api/users';
httpClient.get(httpURL).subscribe();
httpMock.expectOne(httpURL);
const secureURL = 'https://test.com/api/users';
httpClient.get(secureURL).subscribe();
httpMock.expectOne(secureURL);
const protocolRelativeURL = '//test.com/api/users';
httpClient.get(protocolRelativeURL).subscribe();
httpMock.expectOne(protocolRelativeURL);
}
));
});
describe('relative url', () => {
beforeEach(() => {
const request = {
protocol: CONSTANTS.protocol,
get(name: string) {
return name === 'host' ? CONSTANTS.host : '';
}
} as Request;
configureTestModule(request);
});
it('should update relative URL to absolute', inject(
[HttpClient],
(httpClient: HttpClient) => {
const httpMock = TestBed.get(
HttpTestingController
) as HttpTestingController;
const relative_1 = 'api/users';
httpClient.get(relative_1).subscribe();
const absolute_1 = `${CONSTANTS.protocol}://${CONSTANTS.host}/${relative_1}`;
httpMock.expectOne(absolute_1);
const relative_2 = '/api/accounts';
httpClient.get(relative_2).subscribe();
const absolute_2 = `${CONSTANTS.protocol}://${CONSTANTS.host}${relative_2}`;
httpMock.expectOne(absolute_2);
}
));
});
});
Info: With the
inject
function we can replace a token provider in beforeAll and beforeEach hooks, but we cannot vary the provider between test cases or replace it during a test. Hence the use of configureTestModule() and the nested describe functions. See Angular In Depth for more details