Path: blob/main/extensions/github-authentication/src/node/fetch.ts
3320 views
/*---------------------------------------------------------------------------------------------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*--------------------------------------------------------------------------------------------*/45import * as http from 'http';6import * as https from 'https';7import { workspace } from 'vscode';8import { Log } from '../common/logger';9import { Readable } from 'stream';1011export interface FetchOptions {12logger: Log;13retryFallbacks: boolean;14expectJSON: boolean;15method?: 'GET' | 'POST' | 'DELETE';16headers?: Record<string, string>;17body?: string;18signal?: AbortSignal;19}2021export interface FetchHeaders {22get(name: string): string | null;23}2425export interface FetchResponse {26ok: boolean;27status: number;28statusText: string;29headers: FetchHeaders;30text(): Promise<string>;31json(): Promise<any>;32}3334export type Fetch = (url: string, options: FetchOptions) => Promise<FetchResponse>;3536interface Fetcher {37name: string;38fetch: Fetch;39}4041const _fetchers: Fetcher[] = [];42try {43_fetchers.push({44name: 'Electron fetch',45fetch: require('electron').net.fetch46});47} catch {48// ignore49}5051const nodeFetch = {52name: 'Node fetch',53fetch,54};55const useElectronFetch = workspace.getConfiguration('github-authentication').get<boolean>('useElectronFetch', true);56if (useElectronFetch) {57_fetchers.push(nodeFetch);58} else {59_fetchers.unshift(nodeFetch);60}6162_fetchers.push({63name: 'Node http/s',64fetch: nodeHTTP,65});6667export function createFetch(): Fetch {68let fetchers: readonly Fetcher[] = _fetchers;69return async (url, options) => {70const result = await fetchWithFallbacks(fetchers, url, options, options.logger);71if (result.updatedFetchers) {72fetchers = result.updatedFetchers;73}74return result.response;75};76}7778async function fetchWithFallbacks(availableFetchers: readonly Fetcher[], url: string, options: FetchOptions, logService: Log): Promise<{ response: FetchResponse; updatedFetchers?: Fetcher[] }> {79if (options.retryFallbacks && availableFetchers.length > 1) {80let firstResult: { ok: boolean; response: FetchResponse } | { ok: false; err: any } | undefined;81for (const fetcher of availableFetchers) {82const result = await tryFetch(fetcher, url, options, logService);83if (fetcher === availableFetchers[0]) {84firstResult = result;85}86if (!result.ok) {87continue;88}89if (fetcher !== availableFetchers[0]) {90const retry = await tryFetch(availableFetchers[0], url, options, logService);91if (retry.ok) {92return { response: retry.response };93}94logService.info(`FetcherService: using ${fetcher.name} from now on`);95const updatedFetchers = availableFetchers.slice();96updatedFetchers.splice(updatedFetchers.indexOf(fetcher), 1);97updatedFetchers.unshift(fetcher);98return { response: result.response, updatedFetchers };99}100return { response: result.response };101}102if ('response' in firstResult!) {103return { response: firstResult.response };104}105throw firstResult!.err;106}107return { response: await availableFetchers[0].fetch(url, options) };108}109110async function tryFetch(fetcher: Fetcher, url: string, options: FetchOptions, logService: Log): Promise<{ ok: boolean; response: FetchResponse } | { ok: false; err: any }> {111try {112const response = await fetcher.fetch(url, options);113if (!response.ok) {114logService.info(`FetcherService: ${fetcher.name} failed with status: ${response.status} ${response.statusText}`);115return { ok: false, response };116}117if (!options.expectJSON) {118logService.debug(`FetcherService: ${fetcher.name} succeeded (not JSON)`);119return { ok: response.ok, response };120}121const text = await response.text();122try {123const json = JSON.parse(text); // Verify JSON124logService.debug(`FetcherService: ${fetcher.name} succeeded (JSON)`);125return { ok: true, response: new FetchResponseImpl(response.status, response.statusText, response.headers, async () => text, async () => json, async () => Readable.from([text])) };126} catch (err) {127logService.info(`FetcherService: ${fetcher.name} failed to parse JSON: ${err.message}`);128return { ok: false, err, response: new FetchResponseImpl(response.status, response.statusText, response.headers, async () => text, async () => { throw err; }, async () => Readable.from([text])) };129}130} catch (err) {131logService.info(`FetcherService: ${fetcher.name} failed with error: ${err.message}`);132return { ok: false, err };133}134}135136export const fetching = createFetch();137138class FetchResponseImpl implements FetchResponse {139public readonly ok: boolean;140constructor(141public readonly status: number,142public readonly statusText: string,143public readonly headers: FetchHeaders,144public readonly text: () => Promise<string>,145public readonly json: () => Promise<any>,146public readonly body: () => Promise<NodeJS.ReadableStream | null>,147) {148this.ok = this.status >= 200 && this.status < 300;149}150}151152async function nodeHTTP(url: string, options: FetchOptions): Promise<FetchResponse> {153return new Promise((resolve, reject) => {154const { method, headers, body, signal } = options;155const module = url.startsWith('https:') ? https : http;156const req = module.request(url, { method, headers }, res => {157if (signal?.aborted) {158res.destroy();159req.destroy();160reject(makeAbortError(signal));161return;162}163164const nodeFetcherResponse = new NodeFetcherResponse(req, res, signal);165resolve(new FetchResponseImpl(166res.statusCode || 0,167res.statusMessage || '',168nodeFetcherResponse.headers,169async () => nodeFetcherResponse.text(),170async () => nodeFetcherResponse.json(),171async () => nodeFetcherResponse.body(),172));173});174req.setTimeout(60 * 1000); // time out after 60s of receiving no data175req.on('error', reject);176177if (body) {178req.write(body);179}180req.end();181});182}183184class NodeFetcherResponse {185186readonly headers: FetchHeaders;187188constructor(189readonly req: http.ClientRequest,190readonly res: http.IncomingMessage,191readonly signal: AbortSignal | undefined,192) {193this.headers = new class implements FetchHeaders {194get(name: string): string | null {195const result = res.headers[name];196return Array.isArray(result) ? result[0] : result ?? null;197}198[Symbol.iterator](): Iterator<[string, string], any, undefined> {199const keys = Object.keys(res.headers);200let index = 0;201return {202next: (): IteratorResult<[string, string]> => {203if (index >= keys.length) {204return { done: true, value: undefined };205}206const key = keys[index++];207return { done: false, value: [key, this.get(key)!] };208}209};210}211};212}213214public text(): Promise<string> {215return new Promise<string>((resolve, reject) => {216const chunks: Buffer[] = [];217this.res.on('data', chunk => chunks.push(chunk));218this.res.on('end', () => resolve(Buffer.concat(chunks).toString()));219this.res.on('error', reject);220this.signal?.addEventListener('abort', () => {221this.res.destroy();222this.req.destroy();223reject(makeAbortError(this.signal!));224});225});226}227228public async json(): Promise<any> {229const text = await this.text();230return JSON.parse(text);231}232233public async body(): Promise<NodeJS.ReadableStream | null> {234this.signal?.addEventListener('abort', () => {235this.res.emit('error', makeAbortError(this.signal!));236this.res.destroy();237this.req.destroy();238});239return this.res;240}241}242243function makeAbortError(signal: AbortSignal): Error {244// see https://github.com/nodejs/node/issues/38361#issuecomment-1683839467245return signal.reason;246}247248249