Skip to content

Commit 97ffd29

Browse files
JeanMecheChellappanRajan
authored andcommitted
feat(http): Introduction of the fetch Backend for the HttpClient (angular#50247)
This commit introduces a new `HttpBackend` implentation which makes requests using the fetch API This feature is a developer preview and is opt-in. It is enabled by setting the providers with `provideHttpClient(withFetch())`. NB: The fetch API is experimental on Node but available without flags from Node 18 onwards. PR Close angular#50247
1 parent 652d6a8 commit 97ffd29

File tree

8 files changed

+799
-19
lines changed

8 files changed

+799
-19
lines changed

goldens/public-api/common/http/index.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ import { Observable } from 'rxjs';
1313
import { Provider } from '@angular/core';
1414
import { XhrFactory } from '@angular/common';
1515

16+
// @public
17+
export class FetchBackend implements HttpBackend {
18+
// (undocumented)
19+
handle(request: HttpRequest<any>): Observable<HttpEvent<any>>;
20+
// (undocumented)
21+
static ɵfac: i0.ɵɵFactoryDeclaration<FetchBackend, never>;
22+
// (undocumented)
23+
static ɵprov: i0.ɵɵInjectableDeclaration<FetchBackend>;
24+
}
25+
1626
// @public
1727
export const HTTP_INTERCEPTORS: InjectionToken<HttpInterceptor[]>;
1828

@@ -1741,6 +1751,8 @@ export enum HttpFeatureKind {
17411751
// (undocumented)
17421752
CustomXsrfConfiguration = 2,
17431753
// (undocumented)
1754+
Fetch = 6,
1755+
// (undocumented)
17441756
Interceptors = 0,
17451757
// (undocumented)
17461758
JsonpSupport = 4,
@@ -1783,7 +1795,7 @@ export class HttpHeaderResponse extends HttpResponseBase {
17831795
export class HttpHeaders {
17841796
constructor(headers?: string | {
17851797
[name: string]: string | number | (string | number)[];
1786-
});
1798+
} | Headers);
17871799
append(name: string, value: string | string[]): HttpHeaders;
17881800
delete(name: string, value?: string | string[]): HttpHeaders;
17891801
get(name: string): string | null;
@@ -2165,6 +2177,9 @@ export class JsonpInterceptor {
21652177
// @public
21662178
export function provideHttpClient(...features: HttpFeature<HttpFeatureKind>[]): EnvironmentProviders;
21672179

2180+
// @public
2181+
export function withFetch(): HttpFeature<HttpFeatureKind.Fetch>;
2182+
21682183
// @public
21692184
export function withInterceptors(interceptorFns: HttpInterceptorFn[]): HttpFeature<HttpFeatureKind.Interceptors>;
21702185

packages/common/http/public_api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
export {HttpBackend, HttpHandler} from './src/backend';
1010
export {HttpClient} from './src/client';
1111
export {HttpContext, HttpContextToken} from './src/context';
12+
export {FetchBackend} from './src/fetch';
1213
export {HttpHeaders} from './src/headers';
1314
export {HTTP_INTERCEPTORS, HttpHandlerFn, HttpInterceptor, HttpInterceptorFn, HttpInterceptorHandler as ɵHttpInterceptorHandler, HttpInterceptorHandler as ɵHttpInterceptingHandler} from './src/interceptor';
1415
export {JsonpClientBackend, JsonpInterceptor} from './src/jsonp';
1516
export {HttpClientJsonpModule, HttpClientModule, HttpClientXsrfModule} from './src/module';
1617
export {HttpParameterCodec, HttpParams, HttpParamsOptions, HttpUrlEncodingCodec} from './src/params';
17-
export {HttpFeature, HttpFeatureKind, provideHttpClient, withInterceptors, withInterceptorsFromDi, withJsonpSupport, withNoXsrfProtection, withRequestsMadeViaParent, withXsrfConfiguration} from './src/provider';
18+
export {HttpFeature, HttpFeatureKind, provideHttpClient, withFetch, withInterceptors, withInterceptorsFromDi, withJsonpSupport, withNoXsrfProtection, withRequestsMadeViaParent, withXsrfConfiguration} from './src/provider';
1819
export {HttpRequest} from './src/request';
1920
export {HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaderResponse, HttpProgressEvent, HttpResponse, HttpResponseBase, HttpSentEvent, HttpStatusCode, HttpUploadProgressEvent, HttpUserEvent} from './src/response';
2021
export {withHttpTransferCache as ɵwithHttpTransferCache} from './src/transfer_cache';

packages/common/http/src/fetch.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {inject, Injectable} from '@angular/core';
10+
import {Observable, Observer} from 'rxjs';
11+
12+
import {HttpBackend} from './backend';
13+
import {HttpHeaders} from './headers';
14+
import {HttpRequest} from './request';
15+
import {HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaderResponse, HttpResponse, HttpStatusCode} from './response';
16+
17+
const XSSI_PREFIX = /^\)\]\}',?\n/;
18+
19+
const REQUEST_URL_HEADER = `X-Request-URL`;
20+
21+
/**
22+
* Determine an appropriate URL for the response, by checking either
23+
* response url or the X-Request-URL header.
24+
*/
25+
function getResponseUrl(response: Response): string|null {
26+
if (response.url) {
27+
return response.url;
28+
}
29+
// stored as lowercase in the map
30+
const xRequestUrl = REQUEST_URL_HEADER.toLocaleLowerCase();
31+
return response.headers.get(xRequestUrl);
32+
}
33+
34+
/**
35+
* Uses `fetch` to send requests to a backend server.
36+
*
37+
* This `FetchBackend` requires the support of the
38+
* [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) which is available on all
39+
* supported browsers and on Node.js v18 or later.
40+
*
41+
* @see {@link HttpHandler}
42+
*
43+
* @publicApi
44+
* @developerPreview
45+
*/
46+
@Injectable()
47+
export class FetchBackend implements HttpBackend {
48+
// We need to bind the native fetch to its context or it will throw an "illegal invocation"
49+
private readonly fetchImpl =
50+
inject(FetchFactory, {optional: true})?.fetch ?? fetch.bind(globalThis);
51+
52+
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
53+
return new Observable(observer => {
54+
const aborter = new AbortController();
55+
this.doRequest(request, aborter.signal, observer)
56+
.then(noop, error => observer.error(new HttpErrorResponse({error})));
57+
return () => aborter.abort();
58+
});
59+
}
60+
61+
private async doRequest(
62+
request: HttpRequest<any>, signal: AbortSignal,
63+
observer: Observer<HttpEvent<any>>): Promise<void> {
64+
const init = this.createRequestInit(request);
65+
let response;
66+
67+
try {
68+
const fetchPromise = this.fetchImpl(request.url, {signal, ...init});
69+
70+
// Make sure Zone.js doesn't trigger false-positive unhandled promise
71+
// error in case the Promise is rejected synchronously. See function
72+
// description for additional information.
73+
silenceSuperfluousUnhandledPromiseRejection(fetchPromise);
74+
75+
// Send the `Sent` event before awaiting the response.
76+
observer.next({type: HttpEventType.Sent});
77+
78+
response = await fetchPromise;
79+
} catch (error: any) {
80+
observer.error(new HttpErrorResponse({
81+
error,
82+
status: error.status ?? 0,
83+
statusText: error.statusText,
84+
url: request.url,
85+
headers: error.headers,
86+
}));
87+
return;
88+
}
89+
90+
const headers = new HttpHeaders(response.headers);
91+
const statusText = response.statusText;
92+
const url = getResponseUrl(response) ?? request.url;
93+
94+
let status = response.status;
95+
let body: string|ArrayBuffer|Blob|object|null = null;
96+
97+
if (request.reportProgress) {
98+
observer.next(new HttpHeaderResponse({headers, status, statusText, url}));
99+
}
100+
101+
if (response.body) {
102+
// Read Progress
103+
const contentLength = response.headers.get('content-length');
104+
const chunks: Uint8Array[] = [];
105+
const reader = response.body.getReader();
106+
let receivedLength = 0;
107+
108+
let decoder: TextDecoder;
109+
let partialText: string|undefined;
110+
111+
while (true) {
112+
const {done, value} = await reader.read();
113+
114+
if (done) {
115+
break;
116+
}
117+
118+
chunks.push(value);
119+
receivedLength += value.length;
120+
121+
if (request.reportProgress) {
122+
partialText = request.responseType === 'text' ?
123+
(partialText ?? '') + (decoder ??= new TextDecoder).decode(value, {stream: true}) :
124+
undefined;
125+
126+
observer.next({
127+
type: HttpEventType.DownloadProgress,
128+
total: contentLength ? +contentLength : undefined,
129+
loaded: receivedLength,
130+
partialText,
131+
} as HttpDownloadProgressEvent);
132+
}
133+
}
134+
135+
// Combine all chunks.
136+
const chunksAll = this.concatChunks(chunks, receivedLength);
137+
try {
138+
body = this.parseBody(request, chunksAll);
139+
} catch (error) {
140+
// Body loading or parsing failed
141+
observer.error(new HttpErrorResponse({
142+
error,
143+
headers: new HttpHeaders(response.headers),
144+
status: response.status,
145+
statusText: response.statusText,
146+
url: getResponseUrl(response) ?? request.url,
147+
}));
148+
return;
149+
}
150+
}
151+
152+
// Same behavior as the XhrBackend
153+
if (status === 0) {
154+
status = body ? HttpStatusCode.Ok : 0;
155+
}
156+
157+
// ok determines whether the response will be transmitted on the event or
158+
// error channel. Unsuccessful status codes (not 2xx) will always be errors,
159+
// but a successful status code can still result in an error if the user
160+
// asked for JSON data and the body cannot be parsed as such.
161+
const ok = status >= 200 && status < 300;
162+
163+
if (ok) {
164+
observer.next(new HttpResponse({
165+
body,
166+
headers,
167+
status,
168+
statusText,
169+
url,
170+
}));
171+
172+
// The full body has been received and delivered, no further events
173+
// are possible. This request is complete.
174+
observer.complete();
175+
} else {
176+
observer.error(new HttpErrorResponse({
177+
error: body,
178+
headers,
179+
status,
180+
statusText,
181+
url,
182+
}));
183+
}
184+
}
185+
186+
private parseBody(request: HttpRequest<any>, binContent: Uint8Array): string|ArrayBuffer|Blob
187+
|object|null {
188+
switch (request.responseType) {
189+
case 'json':
190+
// stripping the XSSI when present
191+
const text = new TextDecoder().decode(binContent).replace(XSSI_PREFIX, '');
192+
return text === '' ? null : JSON.parse(text) as object;
193+
case 'text':
194+
return new TextDecoder().decode(binContent);
195+
case 'blob':
196+
return new Blob([binContent]);
197+
case 'arraybuffer':
198+
return binContent.buffer;
199+
}
200+
}
201+
202+
private createRequestInit(req: HttpRequest<any>): RequestInit {
203+
// We could share some of this logic with the XhrBackend
204+
205+
const headers: Record<string, string> = {};
206+
const credentials: RequestCredentials|undefined = req.withCredentials ? 'include' : undefined;
207+
208+
// Setting all the requested headers.
209+
req.headers.forEach((name, values) => (headers[name] = values.join(',')));
210+
211+
// Add an Accept header if one isn't present already.
212+
headers['Accept'] ??= 'application/json, text/plain, */*';
213+
214+
// Auto-detect the Content-Type header if one isn't present already.
215+
if (!headers['Content-Type']) {
216+
const detectedType = req.detectContentTypeHeader();
217+
// Sometimes Content-Type detection fails.
218+
if (detectedType !== null) {
219+
headers['Content-Type'] = detectedType;
220+
}
221+
}
222+
223+
return {
224+
body: req.body,
225+
method: req.method,
226+
headers,
227+
credentials,
228+
};
229+
}
230+
231+
private concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array {
232+
const chunksAll = new Uint8Array(totalLength);
233+
let position = 0;
234+
for (const chunk of chunks) {
235+
chunksAll.set(chunk, position);
236+
position += chunk.length;
237+
}
238+
239+
return chunksAll;
240+
}
241+
}
242+
243+
/**
244+
* Abstract class to provide a mocked implementation of `fetch()`
245+
*/
246+
export abstract class FetchFactory {
247+
abstract fetch: typeof fetch;
248+
}
249+
250+
function noop(): void {}
251+
252+
/**
253+
* Zone.js treats a rejected promise that has not yet been awaited
254+
* as an unhandled error. This function adds a noop `.then` to make
255+
* sure that Zone.js doesn't throw an error if the Promise is rejected
256+
* synchronously.
257+
*/
258+
function silenceSuperfluousUnhandledPromiseRejection(promise: Promise<unknown>) {
259+
promise.then(noop, noop);
260+
}

packages/common/http/src/headers.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export class HttpHeaders {
4545

4646
/** Constructs a new HTTP header object with the given values.*/
4747

48-
constructor(headers?: string|{[name: string]: string | number | (string | number)[]}) {
48+
constructor(headers?: string|{[name: string]: string | number | (string | number)[]}|Headers) {
4949
if (!headers) {
5050
this.headers = new Map<string, string[]>();
5151
} else if (typeof headers === 'string') {
@@ -66,28 +66,19 @@ export class HttpHeaders {
6666
}
6767
});
6868
};
69+
} else if (typeof Headers !== 'undefined' && headers instanceof Headers) {
70+
this.headers = new Map<string, string[]>();
71+
headers.forEach((values: string, name: string) => {
72+
this.setHeaderEntries(name, values);
73+
});
6974
} else {
7075
this.lazyInit = () => {
7176
if (typeof ngDevMode === 'undefined' || ngDevMode) {
7277
assertValidHeaders(headers);
7378
}
7479
this.headers = new Map<string, string[]>();
7580
Object.entries(headers).forEach(([name, values]) => {
76-
let headerValues: string[];
77-
78-
if (typeof values === 'string') {
79-
headerValues = [values];
80-
} else if (typeof values === 'number') {
81-
headerValues = [values.toString()];
82-
} else {
83-
headerValues = values.map((value) => value.toString());
84-
}
85-
86-
if (headerValues.length > 0) {
87-
const key = name.toLowerCase();
88-
this.headers.set(key, headerValues);
89-
this.maybeSetNormalizedName(name, key);
90-
}
81+
this.setHeaderEntries(name, values);
9182
});
9283
};
9384
}
@@ -258,6 +249,14 @@ export class HttpHeaders {
258249
}
259250
}
260251

252+
private setHeaderEntries(name: string, values: any) {
253+
const headerValues =
254+
(Array.isArray(values) ? values : [values]).map((value) => value.toString());
255+
const key = name.toLowerCase();
256+
this.headers.set(key, headerValues);
257+
this.maybeSetNormalizedName(name, key);
258+
}
259+
261260
/**
262261
* @internal
263262
*/
@@ -273,7 +272,7 @@ export class HttpHeaders {
273272
* must be either strings, numbers or arrays. Throws an error if an invalid
274273
* header value is present.
275274
*/
276-
function assertValidHeaders(headers: Record<string, unknown>):
275+
function assertValidHeaders(headers: Record<string, unknown>|Headers):
277276
asserts headers is Record<string, string|string[]|number|number[]> {
278277
for (const [key, value] of Object.entries(headers)) {
279278
if (!(typeof value === 'string' || typeof value === 'number') && !Array.isArray(value)) {

0 commit comments

Comments
 (0)