mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
Updates the `HttpBackend` default provider to `FetchBackend`. Also updates related warning messages to reflect the new default behavior.
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
/**
|
|
* @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.dev/license
|
|
*/
|
|
|
|
import {
|
|
DestroyRef,
|
|
ɵformatRuntimeError as formatRuntimeError,
|
|
inject,
|
|
Injectable,
|
|
NgZone,
|
|
} from '@angular/core';
|
|
import {Observable, Observer} from 'rxjs';
|
|
import {RuntimeErrorCode} from './errors';
|
|
|
|
import type {HttpBackend} from './backend';
|
|
import {HttpHeaders} from './headers';
|
|
import {ACCEPT_HEADER, ACCEPT_HEADER_VALUE, CONTENT_TYPE_HEADER, HttpRequest} from './request';
|
|
import {
|
|
HTTP_STATUS_CODE_OK,
|
|
HttpDownloadProgressEvent,
|
|
HttpErrorResponse,
|
|
HttpEvent,
|
|
HttpEventType,
|
|
HttpHeaderResponse,
|
|
HttpResponse,
|
|
} from './response';
|
|
|
|
// Needed for the global `Zone` ambient types to be available.
|
|
import type {} from 'zone.js';
|
|
|
|
const XSSI_PREFIX = /^\)\]\}',?\n/;
|
|
|
|
/**
|
|
* Uses `fetch` to send requests to a backend server.
|
|
*
|
|
* This `FetchBackend` requires the support of the
|
|
* [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) which is available on all
|
|
* supported browsers and on Node.js v18 or later.
|
|
*
|
|
* @see {@link HttpHandler}
|
|
*
|
|
* @publicApi
|
|
*/
|
|
@Injectable({providedIn: 'root'})
|
|
export class FetchBackend implements HttpBackend {
|
|
// We use an arrow function to always reference the current global implementation of `fetch`.
|
|
// This is helpful for cases when the global `fetch` implementation is modified by external code,
|
|
// see https://github.com/angular/angular/issues/57527.
|
|
private readonly fetchImpl =
|
|
inject(FetchFactory, {optional: true})?.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
private readonly ngZone = inject(NgZone);
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
|
|
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
|
|
return new Observable((observer) => {
|
|
const aborter = new AbortController();
|
|
|
|
this.doRequest(request, aborter.signal, observer).then(noop, (error) =>
|
|
observer.error(new HttpErrorResponse({error})),
|
|
);
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
if (request.timeout) {
|
|
// TODO: Replace with AbortSignal.any([aborter.signal, AbortSignal.timeout(request.timeout)])
|
|
// when AbortSignal.any support is Baseline widely available (NET nov. 2026)
|
|
timeoutId = this.ngZone.runOutsideAngular(() =>
|
|
setTimeout(() => {
|
|
if (!aborter.signal.aborted) {
|
|
aborter.abort(new DOMException('signal timed out', 'TimeoutError'));
|
|
}
|
|
}, request.timeout),
|
|
);
|
|
}
|
|
|
|
return () => {
|
|
if (timeoutId !== undefined) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
aborter.abort();
|
|
};
|
|
});
|
|
}
|
|
|
|
private async doRequest(
|
|
request: HttpRequest<any>,
|
|
signal: AbortSignal,
|
|
observer: Observer<HttpEvent<any>>,
|
|
): Promise<void> {
|
|
const init = this.createRequestInit(request);
|
|
let response;
|
|
try {
|
|
// Run fetch outside of Angular zone.
|
|
// This is due to Node.js fetch implementation (Undici) which uses a number of setTimeouts to check if
|
|
// the response should eventually timeout which causes extra CD cycles every 500ms
|
|
const fetchPromise = this.ngZone.runOutsideAngular(() =>
|
|
this.fetchImpl(request.urlWithParams, {signal, ...init}),
|
|
);
|
|
|
|
// Make sure Zone.js doesn't trigger false-positive unhandled promise
|
|
// error in case the Promise is rejected synchronously. See function
|
|
// description for additional information.
|
|
silenceSuperfluousUnhandledPromiseRejection(fetchPromise);
|
|
|
|
// Send the `Sent` event before awaiting the response.
|
|
observer.next({type: HttpEventType.Sent});
|
|
|
|
response = await fetchPromise;
|
|
} catch (error: any) {
|
|
observer.error(
|
|
new HttpErrorResponse({
|
|
error,
|
|
status: error.status ?? 0,
|
|
statusText: error.statusText,
|
|
url: request.urlWithParams,
|
|
headers: error.headers,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const headers = new HttpHeaders(response.headers);
|
|
const statusText = response.statusText;
|
|
const url = response.url || request.urlWithParams;
|
|
|
|
let status = response.status;
|
|
let body: string | ArrayBuffer | Blob | object | null = null;
|
|
|
|
if (request.reportProgress) {
|
|
observer.next(new HttpHeaderResponse({headers, status, statusText, url}));
|
|
}
|
|
|
|
if (response.body) {
|
|
// Read Progress
|
|
const contentLength = response.headers.get('content-length');
|
|
const chunks: Uint8Array[] = [];
|
|
const reader = response.body.getReader();
|
|
let receivedLength = 0;
|
|
|
|
let decoder: TextDecoder;
|
|
let partialText: string | undefined;
|
|
|
|
// We have to check whether the Zone is defined in the global scope because this may be called
|
|
// when the zone is nooped.
|
|
const reqZone = typeof Zone !== 'undefined' && Zone.current;
|
|
|
|
let canceled = false;
|
|
|
|
// Perform response processing outside of Angular zone to
|
|
// ensure no excessive change detection runs are executed
|
|
// Here calling the async ReadableStreamDefaultReader.read() is responsible for triggering CD
|
|
await this.ngZone.runOutsideAngular(async () => {
|
|
while (true) {
|
|
// Prevent reading chunks if the app is destroyed. Otherwise, we risk doing
|
|
// unnecessary work or triggering side effects after teardown.
|
|
// This may happen if the app was explicitly destroyed before
|
|
// the response returned entirely.
|
|
if (this.destroyRef.destroyed) {
|
|
// Streams left in a pending state (due to `break` without cancel) may
|
|
// continue consuming or holding onto data behind the scenes.
|
|
// Calling `reader.cancel()` allows the browser or the underlying
|
|
// system to release any network or memory resources associated with the stream.
|
|
await reader.cancel();
|
|
canceled = true;
|
|
break;
|
|
}
|
|
|
|
const {done, value} = await reader.read();
|
|
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
chunks.push(value);
|
|
receivedLength += value.length;
|
|
|
|
if (request.reportProgress) {
|
|
partialText =
|
|
request.responseType === 'text'
|
|
? (partialText ?? '') +
|
|
(decoder ??= new TextDecoder()).decode(value, {stream: true})
|
|
: undefined;
|
|
|
|
const reportProgress = () =>
|
|
observer.next({
|
|
type: HttpEventType.DownloadProgress,
|
|
total: contentLength ? +contentLength : undefined,
|
|
loaded: receivedLength,
|
|
partialText,
|
|
} as HttpDownloadProgressEvent);
|
|
reqZone ? reqZone.run(reportProgress) : reportProgress();
|
|
}
|
|
}
|
|
});
|
|
|
|
// We need to manage the canceled state — because the Streams API does not
|
|
// expose a direct `.state` property on the reader.
|
|
// We need to `return` because `parseBody` may not be able to parse chunks
|
|
// that were only partially read (due to cancellation caused by app destruction).
|
|
if (canceled) {
|
|
observer.complete();
|
|
return;
|
|
}
|
|
|
|
// Combine all chunks.
|
|
const chunksAll = this.concatChunks(chunks, receivedLength);
|
|
try {
|
|
const contentType = response.headers.get(CONTENT_TYPE_HEADER) ?? '';
|
|
body = this.parseBody(request, chunksAll, contentType, status);
|
|
} catch (error) {
|
|
// Body loading or parsing failed
|
|
observer.error(
|
|
new HttpErrorResponse({
|
|
error,
|
|
headers: new HttpHeaders(response.headers),
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url: response.url || request.urlWithParams,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Same behavior as the XhrBackend
|
|
if (status === 0) {
|
|
status = body ? HTTP_STATUS_CODE_OK : 0;
|
|
}
|
|
|
|
// ok determines whether the response will be transmitted on the event or
|
|
// error channel. Unsuccessful status codes (not 2xx) will always be errors,
|
|
// but a successful status code can still result in an error if the user
|
|
// asked for JSON data and the body cannot be parsed as such.
|
|
const ok = status >= 200 && status < 300;
|
|
|
|
const redirected = response.redirected;
|
|
|
|
const responseType = response.type;
|
|
|
|
if (ok) {
|
|
observer.next(
|
|
new HttpResponse({
|
|
body,
|
|
headers,
|
|
status,
|
|
statusText,
|
|
url,
|
|
redirected,
|
|
responseType,
|
|
}),
|
|
);
|
|
|
|
// The full body has been received and delivered, no further events
|
|
// are possible. This request is complete.
|
|
observer.complete();
|
|
} else {
|
|
observer.error(
|
|
new HttpErrorResponse({
|
|
error: body,
|
|
headers,
|
|
status,
|
|
statusText,
|
|
url,
|
|
redirected,
|
|
responseType,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private parseBody(
|
|
request: HttpRequest<any>,
|
|
binContent: Uint8Array<ArrayBuffer>,
|
|
contentType: string,
|
|
status: number,
|
|
): string | ArrayBuffer | Blob | object | null {
|
|
switch (request.responseType) {
|
|
case 'json':
|
|
// stripping the XSSI when present
|
|
const text = new TextDecoder().decode(binContent).replace(XSSI_PREFIX, '');
|
|
if (text === '') {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(text) as object;
|
|
} catch (e: unknown) {
|
|
// Allow handling non-JSON errors (!) as plain text, same as the XHR
|
|
// backend. Without this special sauce, any non-JSON error would be
|
|
// completely inaccessible downstream as the `HttpErrorResponse.error`
|
|
// would be set to the `SyntaxError` from then failing `JSON.parse`.
|
|
if (status < 200 || status >= 300) {
|
|
return text;
|
|
}
|
|
throw e;
|
|
}
|
|
case 'text':
|
|
return new TextDecoder().decode(binContent);
|
|
case 'blob':
|
|
return new Blob([binContent], {type: contentType});
|
|
case 'arraybuffer':
|
|
return binContent.buffer;
|
|
}
|
|
}
|
|
|
|
private createRequestInit(req: HttpRequest<any>): RequestInit {
|
|
// We could share some of this logic with the XhrBackend
|
|
|
|
const headers: Record<string, string> = {};
|
|
let credentials: RequestCredentials | undefined;
|
|
|
|
// If the request has a credentials property, use it.
|
|
// Otherwise, if the request has withCredentials set to true, use 'include'.
|
|
credentials = req.credentials;
|
|
|
|
// If withCredentials is true should be set to 'include', for compatibility
|
|
if (req.withCredentials) {
|
|
// A warning is logged in development mode if the request has both
|
|
(typeof ngDevMode === 'undefined' || ngDevMode) && warningOptionsMessage(req);
|
|
credentials = 'include';
|
|
}
|
|
|
|
// Setting all the requested headers.
|
|
req.headers.forEach((name, values) => (headers[name] = values.join(',')));
|
|
|
|
// Add an Accept header if one isn't present already.
|
|
if (!req.headers.has(ACCEPT_HEADER)) {
|
|
headers[ACCEPT_HEADER] = ACCEPT_HEADER_VALUE;
|
|
}
|
|
|
|
// Auto-detect the Content-Type header if one isn't present already.
|
|
if (!req.headers.has(CONTENT_TYPE_HEADER)) {
|
|
const detectedType = req.detectContentTypeHeader();
|
|
// Sometimes Content-Type detection fails.
|
|
if (detectedType !== null) {
|
|
headers[CONTENT_TYPE_HEADER] = detectedType;
|
|
}
|
|
}
|
|
|
|
return {
|
|
body: req.serializeBody(),
|
|
method: req.method,
|
|
headers,
|
|
credentials,
|
|
keepalive: req.keepalive,
|
|
cache: req.cache,
|
|
priority: req.priority,
|
|
mode: req.mode,
|
|
redirect: req.redirect,
|
|
referrer: req.referrer,
|
|
integrity: req.integrity,
|
|
referrerPolicy: req.referrerPolicy,
|
|
};
|
|
}
|
|
|
|
private concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array<ArrayBuffer> {
|
|
const chunksAll = new Uint8Array(totalLength);
|
|
let position = 0;
|
|
for (const chunk of chunks) {
|
|
chunksAll.set(chunk, position);
|
|
position += chunk.length;
|
|
}
|
|
|
|
return chunksAll;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abstract class to provide a mocked implementation of `fetch()`
|
|
*/
|
|
export abstract class FetchFactory {
|
|
abstract fetch: typeof fetch;
|
|
}
|
|
|
|
function noop(): void {}
|
|
|
|
function warningOptionsMessage(req: HttpRequest<any>) {
|
|
if (req.credentials && req.withCredentials) {
|
|
console.warn(
|
|
formatRuntimeError(
|
|
RuntimeErrorCode.WITH_CREDENTIALS_OVERRIDES_EXPLICIT_CREDENTIALS,
|
|
`Angular detected that a \`HttpClient\` request has both \`withCredentials: true\` and \`credentials: '${req.credentials}'\` options. The \`withCredentials\` option is overriding the explicit \`credentials\` setting to 'include'. Consider removing \`withCredentials\` and using \`credentials: '${req.credentials}'\` directly for clarity.`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zone.js treats a rejected promise that has not yet been awaited
|
|
* as an unhandled error. This function adds a noop `.then` to make
|
|
* sure that Zone.js doesn't throw an error if the Promise is rejected
|
|
* synchronously.
|
|
*/
|
|
function silenceSuperfluousUnhandledPromiseRejection(promise: Promise<unknown>) {
|
|
promise.then(noop, noop);
|
|
}
|