Skip to content

Commit

Permalink
fix(platform-server): resolve relative requests URL (#52326)
Browse files Browse the repository at this point in the history
Prior to this commit relative HTTP requests were not being resolved to absolute even thought the behaviour is documented in https://angular.io/guide/universal#using-absolute-urls-for-http-data-requests-on-the-server.

This caused relative HTTP requests to fail when done on the server because of missing request context. This change is also required to eventually support HTTP requests handled during prerendering (SSG).

Closes #51626

PR Close #52326
  • Loading branch information
alan-agius4 authored and dylhunn committed Oct 23, 2023
1 parent 9d565cd commit 0c66e24
Show file tree
Hide file tree
Showing 8 changed files with 89 additions and 9 deletions.
7 changes: 5 additions & 2 deletions integration/platform-server/projects/ngmodule/server.ts
Expand Up @@ -38,15 +38,18 @@ app.get('/api-2', (req, res) => {

// All regular routes use the Universal engine
app.get('*', (req, res) => {
const { protocol, originalUrl, baseUrl, headers } = req;

renderModule(AppServerModule, {
document: indexHtml,
url: req.url,
extraProviders: [{provide: APP_BASE_HREF, useValue: req.baseUrl}],
url: `${protocol}://${headers.host}${originalUrl}`,
extraProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
}).then((response: string) => {
res.send(response);
});
});


app.listen(4206, () => {
console.log('Server listening on port 4206!');
});
Expand Up @@ -29,7 +29,7 @@ export class TransferStateComponent implements OnInit {

ngOnInit(): void {
// Test that HTTP cache works when HTTP call is made in a lifecycle hook.
this.httpClient.get<any>('http://localhost:4206/api-2').subscribe((response) => {
this.httpClient.get<any>('/api-2').subscribe((response) => {
this.responseTwo = response.data;
});
}
Expand Down
6 changes: 4 additions & 2 deletions integration/platform-server/projects/standalone/server.ts
Expand Up @@ -38,10 +38,12 @@ app.get('/api-2', (req, res) => {

// All regular routes use the Universal engine
app.get('*', (req, res) => {
const { protocol, originalUrl, baseUrl, headers } = req;

renderApplication(bootstrap, {
document: indexHtml,
url: req.url,
platformProviders: [{provide: APP_BASE_HREF, useValue: req.baseUrl}],
url: `${protocol}://${headers.host}${originalUrl}`,
platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
}).then((response: string) => {
res.send(response);
});
Expand Down
Expand Up @@ -31,7 +31,7 @@ export class TransferStateComponent implements OnInit {

ngOnInit(): void {
// Test that HTTP cache works when HTTP call is made in a lifecycle hook.
this.httpClient.get<any>('http://localhost:4206/api-2').subscribe((response) => {
this.httpClient.get<any>('/api-2').subscribe((response) => {
this.responseTwo = response.data;
});
}
Expand Down
3 changes: 3 additions & 0 deletions packages/common/http/public_api.ts
Expand Up @@ -21,3 +21,6 @@ export {HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType,
export {HttpTransferCacheOptions, withHttpTransferCache as ɵwithHttpTransferCache} from './src/transfer_cache';
export {HttpXhrBackend} from './src/xhr';
export {HttpXsrfTokenExtractor} from './src/xsrf';

// Private exports
export * from './src/private_export';
9 changes: 9 additions & 0 deletions packages/common/http/src/private_export.ts
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

export {HTTP_ROOT_INTERCEPTOR_FNS as ɵHTTP_ROOT_INTERCEPTOR_FNS} from './interceptor';
30 changes: 28 additions & 2 deletions packages/platform-server/src/http.ts
Expand Up @@ -6,8 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/

import {XhrFactory} from '@angular/common';
import {Injectable, Provider} from '@angular/core';
import {PlatformLocation, XhrFactory} from '@angular/common';
import {HttpEvent, HttpHandlerFn, HttpRequest, ɵHTTP_ROOT_INTERCEPTOR_FNS as HTTP_ROOT_INTERCEPTOR_FNS} from '@angular/common/http';
import {inject, Injectable, Provider} from '@angular/core';
import {Observable} from 'rxjs';

@Injectable()
export class ServerXhr implements XhrFactory {
Expand All @@ -34,7 +36,31 @@ export class ServerXhr implements XhrFactory {
}
}

function relativeUrlsTransformerInterceptorFn(
request: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
const platformLocation = inject(PlatformLocation);
const {href, protocol, hostname, port} = platformLocation;
if (!protocol.startsWith('http')) {
return next(request);
}

let urlPrefix = `${protocol}//${hostname}`;
if (port) {
urlPrefix += `:${port}`;
}

const baseHref = platformLocation.getBaseHrefFromDOM() || href;
const baseUrl = new URL(baseHref, urlPrefix);
const newUrl = new URL(request.url, baseUrl).toString();

return next(request.clone({url: newUrl}));
}

export const SERVER_HTTP_PROVIDERS: Provider[] = [
{provide: XhrFactory, useClass: ServerXhr},
{
provide: HTTP_ROOT_INTERCEPTOR_FNS,
useValue: relativeUrlsTransformerInterceptorFn,
multi: true,
},
];
39 changes: 38 additions & 1 deletion packages/platform-server/test/integration_spec.ts
Expand Up @@ -11,7 +11,7 @@ import {animate, AnimationBuilder, state, style, transition, trigger} from '@ang
import {DOCUMENT, isPlatformServer, PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
import {HTTP_INTERCEPTORS, HttpClient, HttpClientModule, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {ApplicationConfig, ApplicationRef, Component, destroyPlatform, EnvironmentProviders, HostListener, Inject, inject as coreInject, Injectable, Input, makeStateKey, mergeApplicationConfig, NgModule, NgZone, PLATFORM_ID, Provider, TransferState, Type, ViewEncapsulation, ɵwhenStable as whenStable} from '@angular/core';
import {ApplicationConfig, ApplicationRef, Component, destroyPlatform, EnvironmentProviders, HostListener, Inject, inject as coreInject, Injectable, Input, makeStateKey, mergeApplicationConfig, NgModule, NgModuleRef, NgZone, PLATFORM_ID, Provider, TransferState, Type, ViewEncapsulation, ɵwhenStable as whenStable} from '@angular/core';
import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils';
import {InitialRenderPendingTasks} from '@angular/core/src/initial_render_pending_tasks';
import {TestBed} from '@angular/core/testing';
Expand Down Expand Up @@ -1147,6 +1147,43 @@ describe('platform-server integration', () => {
});
});
});

describe(`given 'url' is provided in 'INITIAL_CONFIG'`, () => {
let mock: HttpTestingController;
let ref: NgModuleRef<HttpInterceptorExampleModule>;
let http: HttpClient;

beforeEach(async () => {
const platform = platformServer([{
provide: INITIAL_CONFIG,
useValue: {document: '<app></app>', url: 'http://localhost:4000/foo'}
}]);

ref = await platform.bootstrapModule(HttpInterceptorExampleModule);
mock = ref.injector.get(HttpTestingController);
http = ref.injector.get(HttpClient);
});

it('should resolve relative request URLs to absolute', async () => {
ref.injector.get(NgZone).run(() => {
http.get('/testing').subscribe(body => {
NgZone.assertInAngularZone();
expect(body).toEqual('success!');
});
mock.expectOne('http://localhost:4000/testing').flush('success!');
});
});

it(`should not replace the baseUrl of a request when it's absolute`, async () => {
ref.injector.get(NgZone).run(() => {
http.get('http://localhost/testing').subscribe(body => {
NgZone.assertInAngularZone();
expect(body).toEqual('success!');
});
mock.expectOne('http://localhost/testing').flush('success!');
});
});
});
});
});
})();

0 comments on commit 0c66e24

Please sign in to comment.