mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
Enables propagating a trace across XHR callbacks by providing a hook for
wrapping the callback with a function bound to the send trace context.
(cherry picked from commit 07b8e953f4)
444 lines
17 KiB
TypeScript
444 lines
17 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 {XhrFactory} from '../../index';
|
|
import {
|
|
inject,
|
|
Injectable,
|
|
ɵRuntimeError as RuntimeError,
|
|
ɵformatRuntimeError as formatRuntimeError,
|
|
ɵTracingService as TracingService,
|
|
ɵTracingSnapshot as TracingSnapshot,
|
|
} from '@angular/core';
|
|
import {from, Observable, Observer, of} from 'rxjs';
|
|
import {switchMap} from 'rxjs/operators';
|
|
|
|
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, enable Fetch API support by passing \`withFetch()\` as an argument to \`provideHttpClient()\`.`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Progress events are only enabled if requested.
|
|
if (req.reportProgress) {
|
|
// Download progress is always enabled if requested.
|
|
xhr.addEventListener('progress', onDownProgress);
|
|
|
|
// Upload progress depends on whether there is a body to upload.
|
|
if (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 (req.reportProgress) {
|
|
xhr.removeEventListener('progress', onDownProgress);
|
|
if (reqBody !== null && xhr.upload) {
|
|
xhr.upload.removeEventListener('progress', onUpProgress);
|
|
}
|
|
}
|
|
|
|
// Finally, abort the in-flight request.
|
|
if (xhr.readyState !== xhr.DONE) {
|
|
xhr.abort();
|
|
}
|
|
};
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
}
|