/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/common/http/src/xhr.ts
448 строк
18 KB
Matthieu Riegler
refactor(http): Add `reportUploadProgress` & `reportDownloadProgress` options
02 май 2026, 02:00
02 май 2026, 02:00
7c8c334
Код
Авторство
О чём код?
/** * @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 { ɵformatRuntimeError as formatRuntimeError, inject, Injectable, ɵRuntimeError as RuntimeError, ɵTracingService as TracingService, ɵTracingSnapshot as TracingSnapshot, } from '@angular/core'; import {from, Observable, Observer, of} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {XhrFactory} from '../../index'; import type {HttpBackend} from './backend'; import {RuntimeErrorCode} from './errors'; import {HttpHeaders} from './headers'; import {ACCEPT_HEADER, ACCEPT_HEADER_VALUE, CONTENT_TYPE_HEADER, HttpRequest} from './request'; import { HTTP_STATUS_CODE_NO_CONTENT, HTTP_STATUS_CODE_OK, HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaderResponse, HttpJsonParseError, HttpResponse, HttpUploadProgressEvent, } from './response'; const XSSI_PREFIX = /^\)\]\}',?\n/; /** * Validates whether the request is compatible with the XHR backend. * Show a warning if the request contains options that are not supported by XHR. */ function validateXhrCompatibility(req: HttpRequest<any>) { const unsupportedOptions: { property: keyof HttpRequest<any>; errorCode: RuntimeErrorCode; }[] = [ { property: 'keepalive', errorCode: RuntimeErrorCode.KEEPALIVE_NOT_SUPPORTED_WITH_XHR, }, { property: 'cache', errorCode: RuntimeErrorCode.CACHE_NOT_SUPPORTED_WITH_XHR, }, { property: 'priority', errorCode: RuntimeErrorCode.PRIORITY_NOT_SUPPORTED_WITH_XHR, }, { property: 'mode', errorCode: RuntimeErrorCode.MODE_NOT_SUPPORTED_WITH_XHR, }, { property: 'redirect', errorCode: RuntimeErrorCode.REDIRECT_NOT_SUPPORTED_WITH_XHR, }, { property: 'credentials', errorCode: RuntimeErrorCode.CREDENTIALS_NOT_SUPPORTED_WITH_XHR, }, { property: 'integrity', errorCode: RuntimeErrorCode.INTEGRITY_NOT_SUPPORTED_WITH_XHR, }, { property: 'referrer', errorCode: RuntimeErrorCode.REFERRER_NOT_SUPPORTED_WITH_XHR, }, { property: 'referrerPolicy', errorCode: RuntimeErrorCode.REFERRER_POLICY_NOT_SUPPORTED_WITH_XHR, }, ]; // Check each unsupported option and warn if present for (const {property, errorCode} of unsupportedOptions) { if (req[property]) { console.warn( formatRuntimeError( errorCode, `Angular detected that a \`HttpClient\` request with the \`${property}\` option was sent using XHR, which does not support it. To use the \`${property}\` option, use the Fetch API by removing \`withXhr()\` from the \`provideHttpClient()\` call.`, ), ); } } } /** * Uses `XMLHttpRequest` to send requests to a backend server. * @see {@link HttpHandler} * @see {@link JsonpClientBackend} * * @publicApi */ @Injectable({providedIn: 'root'}) export class HttpXhrBackend implements HttpBackend { private readonly tracingService: TracingService<TracingSnapshot> | null = inject(TracingService, { optional: true, }); constructor(private xhrFactory: XhrFactory) {} private maybePropagateTrace<T extends Function>(fn: T): T { return this.tracingService?.propagate ? this.tracingService.propagate(fn) : fn; } /** * Processes a request and returns a stream of response events. * @param req The request object. * @returns An observable of the response events. */ handle(req: HttpRequest<any>): Observable<HttpEvent<any>> { // Quick check to give a better error message when a user attempts to use // HttpClient.jsonp() without installing the HttpClientJsonpModule if (req.method === 'JSONP') { throw new RuntimeError( RuntimeErrorCode.MISSING_JSONP_MODULE, (typeof ngDevMode === 'undefined' || ngDevMode) && `Cannot make a JSONP request without JSONP support. To fix the problem, either add the \`withJsonpSupport()\` call (if \`provideHttpClient()\` is used) or import the \`HttpClientJsonpModule\` in the root NgModule.`, ); } // Validate that the request is compatible with the XHR backend. ngDevMode && validateXhrCompatibility(req); // Check whether this factory has a special function to load an XHR implementation // for various non-browser environments. We currently limit it to only `ServerXhr` // class, which needs to load an XHR implementation. const xhrFactory: XhrFactory & {ɵloadImpl?: () => Promise<void>} = this.xhrFactory; const source: Observable<void | null> = // Note that `ɵloadImpl` is never defined in client bundles and can be // safely dropped whenever we're running in the browser. // This branching is redundant. // The `ngServerMode` guard also enables tree-shaking of the `from()` // function from the common bundle, as it's only used in server code. typeof ngServerMode !== 'undefined' && ngServerMode && xhrFactory.ɵloadImpl ? from(xhrFactory.ɵloadImpl()) : of(null); return source.pipe( switchMap(() => { // Everything happens on Observable subscription. return new Observable((observer: Observer<HttpEvent<any>>) => { // Start by setting up the XHR object with request method, URL, and withCredentials // flag. const xhr = xhrFactory.build(); xhr.open(req.method, req.urlWithParams); if (req.withCredentials) { xhr.withCredentials = true; } // Add all the requested headers. req.headers.forEach((name, values) => xhr.setRequestHeader(name, values.join(','))); // Add an Accept header if one isn't present already. if (!req.headers.has(ACCEPT_HEADER)) { xhr.setRequestHeader(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) { xhr.setRequestHeader(CONTENT_TYPE_HEADER, detectedType); } } if (req.timeout) { xhr.timeout = req.timeout; } // Set the responseType if one was requested. if (req.responseType) { const responseType = req.responseType.toLowerCase(); // JSON responses need to be processed as text. This is because if the server // returns an XSSI-prefixed JSON response, the browser will fail to parse it, // xhr.response will be null, and xhr.responseText cannot be accessed to // retrieve the prefixed JSON data in order to strip the prefix. Thus, all JSON // is parsed by first requesting text and then applying JSON.parse. xhr.responseType = (responseType !== 'json' ? responseType : 'text') as any; } // Serialize the request body if one is present. If not, this will be set to null. const reqBody = req.serializeBody(); // If progress events are enabled, response headers will be delivered // in two events - the HttpHeaderResponse event and the full HttpResponse // event. However, since response headers don't change in between these // two events, it doesn't make sense to parse them twice. So headerResponse // caches the data extracted from the response whenever it's first parsed, // to ensure parsing isn't duplicated. let headerResponse: HttpHeaderResponse | null = null; // partialFromXhr extracts the HttpHeaderResponse from the current XMLHttpRequest // state, and memoizes it into headerResponse. const partialFromXhr = (): HttpHeaderResponse => { if (headerResponse !== null) { return headerResponse; } const statusText = xhr.statusText || 'OK'; // Parse headers from XMLHttpRequest - this step is lazy. const headers = new HttpHeaders(xhr.getAllResponseHeaders()); // Read the response URL from the XMLHttpResponse instance and fall back on the // request URL. const url = xhr.responseURL || req.url; // Construct the HttpHeaderResponse and memoize it. headerResponse = new HttpHeaderResponse({headers, status: xhr.status, statusText, url}); return headerResponse; }; // Next, a few closures are defined for the various events which XMLHttpRequest can // emit. This allows them to be unregistered as event listeners later. // First up is the load event, which represents a response being fully available. const onLoad = this.maybePropagateTrace(() => { // Read response state from the memoized partial data. let {headers, status, statusText, url} = partialFromXhr(); // The body will be read out if present. let body: any | null = null; if (status !== HTTP_STATUS_CODE_NO_CONTENT) { // Use XMLHttpRequest.response if set, responseText otherwise. body = typeof xhr.response === 'undefined' ? xhr.responseText : xhr.response; } // Normalize another potential bug (this one comes from CORS). 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. let ok = status >= 200 && status < 300; // Check whether the body needs to be parsed as JSON (in many cases the browser // will have done that already). if (req.responseType === 'json' && typeof body === 'string') { // Save the original body, before attempting XSSI prefix stripping. const originalBody = body; body = body.replace(XSSI_PREFIX, ''); try { // Attempt the parse. If it fails, a parse error should be delivered to the // user. body = body !== '' ? JSON.parse(body) : null; } catch (error) { // Since the JSON.parse failed, it's reasonable to assume this might not have // been a JSON response. Restore the original body (including any XSSI prefix) // to deliver a better error response. body = originalBody; // If this was an error request to begin with, leave it as a string, it // probably just isn't JSON. Otherwise, deliver the parsing error to the user. if (ok) { // Even though the response status was 2xx, this is still an error. ok = false; // The parse error contains the text of the body that failed to parse. body = {error, text: body} as HttpJsonParseError; } } } if (ok) { // A successful response is delivered on the event stream. observer.next( new HttpResponse({ body, headers, status, statusText, url: url || undefined, }), ); // The full body has been received and delivered, no further events // are possible. This request is complete. observer.complete(); } else { // An unsuccessful request is delivered on the error channel. observer.error( new HttpErrorResponse({ // The error in this case is the response body (error from the server). error: body, headers, status, statusText, url: url || undefined, }), ); } }); // The onError callback is called when something goes wrong at the network level. // Connection timeout, DNS error, offline, etc. These are actual errors, and are // transmitted on the error channel. const onError = this.maybePropagateTrace((error: ProgressEvent) => { const {url} = partialFromXhr(); const res = new HttpErrorResponse({ error, status: xhr.status || 0, statusText: xhr.statusText || 'Unknown Error', url: url || undefined, }); observer.error(res); }); let onTimeout = onError; if (req.timeout) { onTimeout = this.maybePropagateTrace((_: ProgressEvent) => { const {url} = partialFromXhr(); const res = new HttpErrorResponse({ error: new DOMException('Request timed out', 'TimeoutError'), status: xhr.status || 0, statusText: xhr.statusText || 'Request timeout', url: url || undefined, }); observer.error(res); }); } // The sentHeaders flag tracks whether the HttpResponseHeaders event // has been sent on the stream. This is necessary to track if progress // is enabled since the event will be sent on only the first download // progress event. let sentHeaders = false; // The download progress event handler, which is only registered if // progress events are enabled. const onDownProgress = this.maybePropagateTrace((event: ProgressEvent) => { // Send the HttpResponseHeaders event if it hasn't been sent already. if (!sentHeaders) { observer.next(partialFromXhr()); sentHeaders = true; } // Start building the download progress event to deliver on the response // event stream. let progressEvent: HttpDownloadProgressEvent = { type: HttpEventType.DownloadProgress, loaded: event.loaded, }; // Set the total number of bytes in the event if it's available. if (event.lengthComputable) { progressEvent.total = event.total; } // If the request was for text content and a partial response is // available on XMLHttpRequest, include it in the progress event // to allow for streaming reads. if (req.responseType === 'text' && !!xhr.responseText) { progressEvent.partialText = xhr.responseText; } // Finally, fire the event. observer.next(progressEvent); }); // The upload progress event handler, which is only registered if // progress events are enabled. const onUpProgress = this.maybePropagateTrace((event: ProgressEvent) => { // Upload progress events are simpler. Begin building the progress // event. let progress: HttpUploadProgressEvent = { type: HttpEventType.UploadProgress, loaded: event.loaded, }; // If the total number of bytes being uploaded is available, include // it. if (event.lengthComputable) { progress.total = event.total; } // Send the event. observer.next(progress); }); // By default, register for load and error events. xhr.addEventListener('load', onLoad); xhr.addEventListener('error', onError); xhr.addEventListener('timeout', onTimeout); xhr.addEventListener('abort', onError); const reportUploadProgress = req.reportProgress || req.reportUploadProgress; const reportDownloadProgress = req.reportProgress || req.reportDownloadProgress; // Progress events are only enabled if requested. if (reportDownloadProgress) { // Download progress is always enabled if requested. xhr.addEventListener('progress', onDownProgress); } // Upload progress depends on whether there is a body to upload. if (reportUploadProgress && reqBody !== null && xhr.upload) { xhr.upload.addEventListener('progress', onUpProgress); } // Fire the request, and notify the event stream that it was fired. xhr.send(reqBody!); observer.next({type: HttpEventType.Sent}); // This is the return from the Observable function, which is the // request cancellation handler. return () => { // On a cancellation, remove all registered event listeners. xhr.removeEventListener('error', onError); xhr.removeEventListener('abort', onError); xhr.removeEventListener('load', onLoad); xhr.removeEventListener('timeout', onTimeout); if (reportDownloadProgress) { xhr.removeEventListener('progress', onDownProgress); } if (reportUploadProgress && reqBody !== null && xhr.upload) { xhr.upload.removeEventListener('progress', onUpProgress); } // Finally, abort the in-flight request. if (xhr.readyState !== xhr.DONE) { xhr.abort(); } }; }); }), ); } }