Skip to content

Commit a5dfa47

Browse files
Copiloticlanton
andauthored
DRY up duplicated code across WebClient, AmazonS3Client, and HttpBuildCacheProvider
Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/c103fb17-b8b1-4d02-88ba-9db236e9f48f Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com>
1 parent 704485d commit a5dfa47

3 files changed

Lines changed: 337 additions & 341 deletions

File tree

libraries/rush-lib/src/utilities/WebClient.ts

Lines changed: 157 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -95,169 +95,49 @@ const ACCEPT_HEADER_NAME: 'accept' = 'accept';
9595
const USER_AGENT_HEADER_NAME: 'user-agent' = 'user-agent';
9696
const CONTENT_ENCODING_HEADER_NAME: 'content-encoding' = 'content-encoding';
9797

98-
const makeRequestAsync: FetchFn = async (
99-
url: string,
100-
options: IRequestOptions,
101-
redirected: boolean = false
102-
) => {
103-
const { body, redirect, noDecode } = options;
104-
105-
return await new Promise(
106-
(resolve: (result: IWebClientResponse) => void, reject: (error: Error) => void) => {
107-
const parsedUrl: URL = typeof url === 'string' ? new URL(url) : url;
108-
const requestFunction: typeof httpRequest | typeof httpsRequest =
109-
parsedUrl.protocol === 'https:' ? httpsRequest : httpRequest;
110-
111-
const req: ClientRequest = requestFunction(url, options, (response: IncomingMessage) => {
112-
const responseBuffers: (Buffer | Uint8Array)[] = [];
113-
response.on('data', (chunk: string | Buffer | Uint8Array) => {
114-
responseBuffers.push(Buffer.from(chunk));
115-
});
116-
response.on('end', () => {
117-
// Handle retries by calling the method recursively with the redirect URL
118-
const statusCode: number | undefined = response.statusCode;
119-
if (statusCode === 301 || statusCode === 302) {
120-
switch (redirect) {
121-
case 'follow': {
122-
const redirectUrl: string | string[] | undefined = response.headers.location;
123-
if (redirectUrl) {
124-
makeRequestAsync(redirectUrl, options, true).then(resolve).catch(reject);
125-
} else {
126-
reject(
127-
new Error(`Received status code ${response.statusCode} with no location header: ${url}`)
128-
);
129-
}
130-
131-
break;
132-
}
133-
case 'error':
134-
reject(new Error(`Received status code ${response.statusCode}: ${url}`));
135-
return;
136-
}
137-
}
138-
139-
const responseData: Buffer = Buffer.concat(responseBuffers);
140-
const status: number = response.statusCode || 0;
141-
const statusText: string | undefined = response.statusMessage;
142-
const headers: Record<string, string | string[] | undefined> = response.headers;
143-
144-
let bodyString: string | undefined;
145-
let bodyJson: unknown | undefined;
146-
let decodedBuffer: Buffer | undefined;
147-
const result: IWebClientResponse = {
148-
ok: status >= 200 && status < 300,
149-
status,
150-
statusText,
151-
redirected,
152-
headers,
153-
getTextAsync: async () => {
154-
if (bodyString === undefined) {
155-
const buffer: Buffer = await result.getBufferAsync();
156-
// eslint-disable-next-line require-atomic-updates
157-
bodyString = buffer.toString();
158-
}
159-
160-
return bodyString;
161-
},
162-
getJsonAsync: async <TJson>() => {
163-
if (bodyJson === undefined) {
164-
const text: string = await result.getTextAsync();
165-
// eslint-disable-next-line require-atomic-updates
166-
bodyJson = JSON.parse(text);
167-
}
168-
169-
return bodyJson as TJson;
170-
},
171-
getBufferAsync: async () => {
172-
// Determine if the buffer is compressed and decode it if necessary
173-
if (decodedBuffer === undefined) {
174-
let encodings: string | string[] | undefined = headers[CONTENT_ENCODING_HEADER_NAME];
175-
if (!noDecode && encodings !== undefined) {
176-
const zlib: typeof import('zlib') = await import('node:zlib');
177-
if (!Array.isArray(encodings)) {
178-
encodings = encodings.split(',');
179-
}
180-
181-
let buffer: Buffer = responseData;
182-
for (const encoding of encodings) {
183-
let decompressFn: (buffer: Buffer, callback: import('zlib').CompressCallback) => void;
184-
switch (encoding.trim()) {
185-
case DEFLATE_ENCODING: {
186-
decompressFn = zlib.inflate.bind(zlib);
187-
break;
188-
}
189-
case GZIP_ENCODING: {
190-
decompressFn = zlib.gunzip.bind(zlib);
191-
break;
192-
}
193-
case BROTLI_ENCODING: {
194-
decompressFn = zlib.brotliDecompress.bind(zlib);
195-
break;
196-
}
197-
default: {
198-
throw new Error(`Unsupported content-encoding: ${encodings}`);
199-
}
200-
}
201-
202-
buffer = await LegacyAdapters.convertCallbackToPromise(decompressFn, buffer);
203-
}
204-
205-
// eslint-disable-next-line require-atomic-updates
206-
decodedBuffer = buffer;
207-
} else {
208-
decodedBuffer = responseData;
209-
}
210-
}
211-
212-
return decodedBuffer;
213-
}
214-
};
215-
resolve(result);
216-
});
217-
}).on('error', (error: Error) => {
218-
reject(error);
219-
});
220-
221-
_sendRequestBody(req, body, reject);
222-
}
223-
);
224-
};
225-
22698
type StreamFetchFn = (
22799
url: string,
228100
options: IRequestOptions,
229101
isRedirect?: boolean
230102
) => Promise<IWebClientStreamResponse>;
231103

232104
/**
233-
* Makes an HTTP request that resolves as soon as headers are received, providing the
234-
* response body as a readable stream. This avoids buffering the entire response in memory.
105+
* Shared HTTP request core used by both buffer-based and streaming request functions.
106+
* Handles URL parsing, protocol selection, redirect following, body sending, and error handling.
107+
* The `handleResponse` callback is responsible for processing the response and calling
108+
* `resolve`/`reject` to complete the outer promise.
235109
*/
236-
const makeStreamRequestAsync: StreamFetchFn = async (
110+
function _makeRawRequestAsync<TResponse>(
237111
url: string,
238112
options: IRequestOptions,
239-
redirected: boolean = false
240-
) => {
113+
redirected: boolean,
114+
handleResponse: (
115+
response: IncomingMessage,
116+
redirected: boolean,
117+
resolve: (result: TResponse | PromiseLike<TResponse>) => void,
118+
reject: (error: Error) => void
119+
) => void,
120+
selfFn: (url: string, options: IRequestOptions, isRedirect?: boolean) => Promise<TResponse>
121+
): Promise<TResponse> {
241122
const { body, redirect } = options;
242123

243-
return await new Promise(
244-
(resolve: (result: IWebClientStreamResponse) => void, reject: (error: Error) => void) => {
124+
return new Promise(
125+
(resolve: (result: TResponse | PromiseLike<TResponse>) => void, reject: (error: Error) => void) => {
245126
const parsedUrl: URL = typeof url === 'string' ? new URL(url) : url;
246127
const requestFunction: typeof httpRequest | typeof httpsRequest =
247128
parsedUrl.protocol === 'https:' ? httpsRequest : httpRequest;
248129

249130
const req: ClientRequest = requestFunction(url, options, (response: IncomingMessage) => {
250131
const statusCode: number | undefined = response.statusCode;
251132

252-
// Handle redirects
253133
if (statusCode === 301 || statusCode === 302) {
254-
// Consume/drain the redirect response before following
134+
// Drain the redirect response before following
255135
response.resume();
256136
switch (redirect) {
257137
case 'follow': {
258138
const redirectUrl: string | string[] | undefined = response.headers.location;
259-
if (redirectUrl) {
260-
makeStreamRequestAsync(redirectUrl, options, true).then(resolve).catch(reject);
139+
if (typeof redirectUrl === 'string') {
140+
resolve(selfFn(redirectUrl, options, true));
261141
} else {
262142
reject(
263143
new Error(`Received status code ${response.statusCode} with no location header: ${url}`)
@@ -271,40 +151,156 @@ const makeStreamRequestAsync: StreamFetchFn = async (
271151
}
272152
}
273153

154+
handleResponse(response, redirected, resolve, reject);
155+
}).on('error', (error: Error) => {
156+
reject(error);
157+
});
158+
159+
const isStream: boolean = !!body && typeof (body as Readable).pipe === 'function';
160+
if (isStream) {
161+
(body as Readable).on('error', reject);
162+
(body as Readable).pipe(req);
163+
} else {
164+
req.end(body as Buffer | undefined);
165+
}
166+
}
167+
);
168+
}
169+
170+
const makeRequestAsync: FetchFn = async (
171+
url: string,
172+
options: IRequestOptions,
173+
redirected: boolean = false
174+
) => {
175+
const { noDecode } = options;
176+
177+
return _makeRawRequestAsync(
178+
url,
179+
options,
180+
redirected,
181+
(
182+
response: IncomingMessage,
183+
wasRedirected: boolean,
184+
resolve: (result: IWebClientResponse | PromiseLike<IWebClientResponse>) => void
185+
): void => {
186+
const responseBuffers: (Buffer | Uint8Array)[] = [];
187+
response.on('data', (chunk: string | Buffer | Uint8Array) => {
188+
responseBuffers.push(Buffer.from(chunk));
189+
});
190+
response.on('end', () => {
191+
const responseData: Buffer = Buffer.concat(responseBuffers);
274192
const status: number = response.statusCode || 0;
275193
const statusText: string | undefined = response.statusMessage;
276194
const headers: Record<string, string | string[] | undefined> = response.headers;
277195

278-
resolve({
196+
let bodyString: string | undefined;
197+
let bodyJson: unknown | undefined;
198+
let decodedBuffer: Buffer | undefined;
199+
const result: IWebClientResponse = {
279200
ok: status >= 200 && status < 300,
280201
status,
281202
statusText,
282-
redirected,
203+
redirected: wasRedirected,
283204
headers,
284-
stream: response
285-
});
286-
}).on('error', (error: Error) => {
287-
reject(error);
288-
});
205+
getTextAsync: async () => {
206+
if (bodyString === undefined) {
207+
const buffer: Buffer = await result.getBufferAsync();
208+
// eslint-disable-next-line require-atomic-updates
209+
bodyString = buffer.toString();
210+
}
289211

290-
_sendRequestBody(req, body, reject);
291-
}
212+
return bodyString;
213+
},
214+
getJsonAsync: async <TJson>() => {
215+
if (bodyJson === undefined) {
216+
const text: string = await result.getTextAsync();
217+
// eslint-disable-next-line require-atomic-updates
218+
bodyJson = JSON.parse(text);
219+
}
220+
221+
return bodyJson as TJson;
222+
},
223+
getBufferAsync: async () => {
224+
// Determine if the buffer is compressed and decode it if necessary
225+
if (decodedBuffer === undefined) {
226+
let encodings: string | string[] | undefined = headers[CONTENT_ENCODING_HEADER_NAME];
227+
if (!noDecode && encodings !== undefined) {
228+
const zlib: typeof import('zlib') = await import('node:zlib');
229+
if (!Array.isArray(encodings)) {
230+
encodings = encodings.split(',');
231+
}
232+
233+
let buffer: Buffer = responseData;
234+
for (const encoding of encodings) {
235+
let decompressFn: (buffer: Buffer, callback: import('zlib').CompressCallback) => void;
236+
switch (encoding.trim()) {
237+
case DEFLATE_ENCODING: {
238+
decompressFn = zlib.inflate.bind(zlib);
239+
break;
240+
}
241+
case GZIP_ENCODING: {
242+
decompressFn = zlib.gunzip.bind(zlib);
243+
break;
244+
}
245+
case BROTLI_ENCODING: {
246+
decompressFn = zlib.brotliDecompress.bind(zlib);
247+
break;
248+
}
249+
default: {
250+
throw new Error(`Unsupported content-encoding: ${encodings}`);
251+
}
252+
}
253+
254+
buffer = await LegacyAdapters.convertCallbackToPromise(decompressFn, buffer);
255+
}
256+
257+
// eslint-disable-next-line require-atomic-updates
258+
decodedBuffer = buffer;
259+
} else {
260+
decodedBuffer = responseData;
261+
}
262+
}
263+
264+
return decodedBuffer;
265+
}
266+
};
267+
resolve(result);
268+
});
269+
},
270+
makeRequestAsync
292271
);
293272
};
294273

295-
function _sendRequestBody(
296-
req: ClientRequest,
297-
body: Buffer | Readable | undefined,
298-
reject: (error: Error) => void
299-
): void {
300-
const isStream: boolean = !!body && typeof (body as Readable).pipe === 'function';
301-
if (isStream) {
302-
(body as Readable).on('error', reject);
303-
(body as Readable).pipe(req);
304-
} else {
305-
req.end(body as Buffer | undefined);
306-
}
307-
}
274+
const makeStreamRequestAsync: StreamFetchFn = async (
275+
url: string,
276+
options: IRequestOptions,
277+
redirected: boolean = false
278+
) => {
279+
return _makeRawRequestAsync(
280+
url,
281+
options,
282+
redirected,
283+
(
284+
response: IncomingMessage,
285+
wasRedirected: boolean,
286+
resolve: (result: IWebClientStreamResponse | PromiseLike<IWebClientStreamResponse>) => void
287+
): void => {
288+
const status: number = response.statusCode || 0;
289+
const statusText: string | undefined = response.statusMessage;
290+
const headers: Record<string, string | string[] | undefined> = response.headers;
291+
292+
resolve({
293+
ok: status >= 200 && status < 300,
294+
status,
295+
statusText,
296+
redirected: wasRedirected,
297+
headers,
298+
stream: response
299+
});
300+
},
301+
makeStreamRequestAsync
302+
);
303+
};
308304

309305
/**
310306
* A helper for issuing HTTP requests.

0 commit comments

Comments
 (0)