UNPKG

8.38 kBPlain TextView Raw
1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License. See License.txt in the project root for license information.
3
4import AbortController from "abort-controller";
5import FormData from "form-data";
6
7import { HttpClient } from "./httpClient";
8import { WebResourceLike } from "./webResource";
9import { HttpOperationResponse } from "./httpOperationResponse";
10import { HttpHeaders, HttpHeadersLike } from "./httpHeaders";
11import { RestError } from "./restError";
12import { Readable, Transform } from "stream";
13
14interface FetchError extends Error {
15 code?: string;
16 errno?: string;
17 type?: string;
18}
19
20export type CommonRequestInfo = string; // we only call fetch() on string urls.
21
22export type CommonRequestInit = Omit<RequestInit, "body" | "headers" | "signal"> & {
23 body?: any;
24 headers?: any;
25 signal?: any;
26};
27
28export type CommonResponse = Omit<Response, "body" | "trailer" | "formData"> & {
29 body: any;
30 trailer: any;
31 formData: any;
32};
33
34export abstract class FetchHttpClient implements HttpClient {
35 async sendRequest(httpRequest: WebResourceLike): Promise<HttpOperationResponse> {
36 if (!httpRequest && typeof httpRequest !== "object") {
37 throw new Error(
38 "'httpRequest' (WebResource) cannot be null or undefined and must be of type object."
39 );
40 }
41
42 const abortController = new AbortController();
43 let abortListener: ((event: any) => void) | undefined;
44 if (httpRequest.abortSignal) {
45 if (httpRequest.abortSignal.aborted) {
46 throw new RestError(
47 "The request was aborted",
48 RestError.REQUEST_ABORTED_ERROR,
49 undefined,
50 httpRequest
51 );
52 }
53
54 abortListener = (event: Event) => {
55 if (event.type === "abort") {
56 abortController.abort();
57 }
58 };
59 httpRequest.abortSignal.addEventListener("abort", abortListener);
60 }
61
62 if (httpRequest.timeout) {
63 setTimeout(() => {
64 abortController.abort();
65 }, httpRequest.timeout);
66 }
67
68 if (httpRequest.formData) {
69 const formData: any = httpRequest.formData;
70 const requestForm = new FormData();
71 const appendFormValue = (key: string, value: any) => {
72 // value function probably returns a stream so we can provide a fresh stream on each retry
73 if (typeof value === "function") {
74 value = value();
75 }
76 if (value && value.hasOwnProperty("value") && value.hasOwnProperty("options")) {
77 requestForm.append(key, value.value, value.options);
78 } else {
79 requestForm.append(key, value);
80 }
81 };
82 for (const formKey of Object.keys(formData)) {
83 const formValue = formData[formKey];
84 if (Array.isArray(formValue)) {
85 for (let j = 0; j < formValue.length; j++) {
86 appendFormValue(formKey, formValue[j]);
87 }
88 } else {
89 appendFormValue(formKey, formValue);
90 }
91 }
92
93 httpRequest.body = requestForm;
94 httpRequest.formData = undefined;
95 const contentType = httpRequest.headers.get("Content-Type");
96 if (contentType && contentType.indexOf("multipart/form-data") !== -1) {
97 if (typeof requestForm.getBoundary === "function") {
98 httpRequest.headers.set(
99 "Content-Type",
100 `multipart/form-data; boundary=${requestForm.getBoundary()}`
101 );
102 } else {
103 // browser will automatically apply a suitable content-type header
104 httpRequest.headers.remove("Content-Type");
105 }
106 }
107 }
108
109 let body = httpRequest.body
110 ? typeof httpRequest.body === "function"
111 ? httpRequest.body()
112 : httpRequest.body
113 : undefined;
114 if (httpRequest.onUploadProgress && httpRequest.body) {
115 let loadedBytes = 0;
116 const uploadReportStream = new Transform({
117 transform: (chunk: string | Buffer, _encoding, callback) => {
118 loadedBytes += chunk.length;
119 httpRequest.onUploadProgress!({ loadedBytes });
120 callback(undefined, chunk);
121 },
122 });
123
124 if (isReadableStream(body)) {
125 body.pipe(uploadReportStream);
126 } else {
127 uploadReportStream.end(body);
128 }
129
130 body = uploadReportStream;
131 }
132
133 const platformSpecificRequestInit: Partial<RequestInit> = await this.prepareRequest(
134 httpRequest
135 );
136
137 const requestInit: RequestInit = {
138 body: body,
139 headers: httpRequest.headers.rawHeaders(),
140 method: httpRequest.method,
141 signal: abortController.signal,
142 redirect: "manual",
143 ...platformSpecificRequestInit,
144 };
145
146 let operationResponse: HttpOperationResponse | undefined;
147 try {
148 const response: CommonResponse = await this.fetch(httpRequest.url, requestInit);
149
150 const headers = parseHeaders(response.headers);
151 operationResponse = {
152 headers: headers,
153 request: httpRequest,
154 status: response.status,
155 readableStreamBody: httpRequest.streamResponseBody
156 ? ((response.body as unknown) as NodeJS.ReadableStream)
157 : undefined,
158 bodyAsText: !httpRequest.streamResponseBody ? await response.text() : undefined,
159 redirected: response.redirected,
160 url: response.url,
161 };
162
163 const onDownloadProgress = httpRequest.onDownloadProgress;
164 if (onDownloadProgress) {
165 const responseBody: ReadableStream<Uint8Array> | undefined = response.body || undefined;
166
167 if (isReadableStream(responseBody)) {
168 let loadedBytes = 0;
169 const downloadReportStream = new Transform({
170 transform: (chunk: string | Buffer, _encoding, callback) => {
171 loadedBytes += chunk.length;
172 onDownloadProgress({ loadedBytes });
173 callback(undefined, chunk);
174 },
175 });
176 responseBody.pipe(downloadReportStream);
177 operationResponse.readableStreamBody = downloadReportStream;
178 } else {
179 const length = parseInt(headers.get("Content-Length")!) || undefined;
180 if (length) {
181 // Calling callback for non-stream response for consistency with browser
182 onDownloadProgress({ loadedBytes: length });
183 }
184 }
185 }
186
187 await this.processRequest(operationResponse);
188
189 return operationResponse;
190 } catch (error) {
191 const fetchError: FetchError = error;
192 if (fetchError.code === "ENOTFOUND") {
193 throw new RestError(
194 fetchError.message,
195 RestError.REQUEST_SEND_ERROR,
196 undefined,
197 httpRequest
198 );
199 } else if (fetchError.type === "aborted") {
200 throw new RestError(
201 "The request was aborted",
202 RestError.REQUEST_ABORTED_ERROR,
203 undefined,
204 httpRequest
205 );
206 }
207
208 throw fetchError;
209 } finally {
210 // clean up event listener
211 if (httpRequest.abortSignal && abortListener) {
212 let uploadStreamDone = Promise.resolve();
213 if (isReadableStream(body)) {
214 uploadStreamDone = isStreamComplete(body);
215 }
216 let downloadStreamDone = Promise.resolve();
217 if (isReadableStream(operationResponse?.readableStreamBody)) {
218 downloadStreamDone = isStreamComplete(operationResponse!.readableStreamBody);
219 }
220
221 Promise.all([uploadStreamDone, downloadStreamDone])
222 .then(() => {
223 httpRequest.abortSignal?.removeEventListener("abort", abortListener!);
224 return;
225 })
226 .catch((_e) => {});
227 }
228 }
229 }
230
231 abstract async prepareRequest(httpRequest: WebResourceLike): Promise<Partial<RequestInit>>;
232 abstract async processRequest(operationResponse: HttpOperationResponse): Promise<void>;
233 abstract async fetch(input: CommonRequestInfo, init?: CommonRequestInit): Promise<CommonResponse>;
234}
235
236function isReadableStream(body: any): body is Readable {
237 return body && typeof body.pipe === "function";
238}
239
240function isStreamComplete(stream: Readable): Promise<void> {
241 return new Promise((resolve) => {
242 stream.on("close", resolve);
243 stream.on("end", resolve);
244 stream.on("error", resolve);
245 });
246}
247
248export function parseHeaders(headers: Headers): HttpHeadersLike {
249 const httpHeaders = new HttpHeaders();
250
251 headers.forEach((value, key) => {
252 httpHeaders.set(key, value);
253 });
254
255 return httpHeaders;
256}