mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-23 10:08:49 +01:00
Fall back to alternate fetch implementation (#262181)
This commit is contained in:
@@ -132,6 +132,8 @@ async function exchangeCodeForToken(
|
||||
body.append('github_enterprise', enterpriseUri.toString(true));
|
||||
}
|
||||
const result = await fetching(endpointUri.toString(true), {
|
||||
logger,
|
||||
expectJSON: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -339,6 +341,8 @@ class DeviceCodeFlow implements IFlow {
|
||||
query: `client_id=${Config.gitHubClientId}&scope=${scopes}`
|
||||
});
|
||||
const result = await fetching(uri.toString(true), {
|
||||
logger,
|
||||
expectJSON: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
@@ -373,10 +377,11 @@ class DeviceCodeFlow implements IFlow {
|
||||
const uriToOpen = await env.asExternalUri(open);
|
||||
await env.openExternal(uriToOpen);
|
||||
|
||||
return await this.waitForDeviceCodeAccessToken(baseUri, json);
|
||||
return await this.waitForDeviceCodeAccessToken(logger, baseUri, json);
|
||||
}
|
||||
|
||||
private async waitForDeviceCodeAccessToken(
|
||||
logger: Log,
|
||||
baseUri: Uri,
|
||||
json: IGitHubDeviceCodeResponse,
|
||||
): Promise<string> {
|
||||
@@ -407,6 +412,8 @@ class DeviceCodeFlow implements IFlow {
|
||||
let accessTokenResult;
|
||||
try {
|
||||
accessTokenResult = await fetching(refreshTokenUri.toString(true), {
|
||||
logger,
|
||||
expectJSON: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
@@ -500,6 +507,8 @@ class PatFlow implements IFlow {
|
||||
try {
|
||||
logger.info('Getting token scopes...');
|
||||
const result = await fetching(serverUri.toString(), {
|
||||
logger,
|
||||
expectJSON: false,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'User-Agent': `${env.appName} (${env.appHost})`
|
||||
|
||||
@@ -177,6 +177,8 @@ export class GitHubServer implements IGitHubServer {
|
||||
try {
|
||||
// Defined here: https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-token
|
||||
const result = await fetching(uri.toString(true), {
|
||||
logger: this._logger,
|
||||
expectJSON: false,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
@@ -218,6 +220,8 @@ export class GitHubServer implements IGitHubServer {
|
||||
try {
|
||||
this._logger.info('Getting user info...');
|
||||
result = await fetching(this.getServerUri('/user').toString(), {
|
||||
logger: this._logger,
|
||||
expectJSON: true,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
|
||||
@@ -276,6 +280,8 @@ export class GitHubServer implements IGitHubServer {
|
||||
|
||||
try {
|
||||
const result = await fetching('https://education.github.com/api/user', {
|
||||
logger: this._logger,
|
||||
expectJSON: true,
|
||||
headers: {
|
||||
Authorization: `token ${session.accessToken}`,
|
||||
'faculty-check-preview': 'true',
|
||||
@@ -316,6 +322,8 @@ export class GitHubServer implements IGitHubServer {
|
||||
let version: string;
|
||||
if (!isSupportedTarget(this._type, this._ghesUri)) {
|
||||
const result = await fetching(this.getServerUri('/meta').toString(), {
|
||||
logger: this._logger,
|
||||
expectJSON: true,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
|
||||
|
||||
@@ -3,19 +3,231 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { workspace } from 'vscode';
|
||||
import { Log } from '../common/logger';
|
||||
|
||||
let _fetch: typeof fetch;
|
||||
|
||||
const useElectronFetch = workspace.getConfiguration('github-authentication').get<boolean>('useElectronFetch', true);
|
||||
if (useElectronFetch) {
|
||||
try {
|
||||
_fetch = require('electron').net.fetch;
|
||||
} catch {
|
||||
_fetch = fetch;
|
||||
}
|
||||
} else {
|
||||
_fetch = fetch;
|
||||
export interface FetchOptions {
|
||||
logger: Log;
|
||||
expectJSON: boolean;
|
||||
method?: 'GET' | 'POST' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export const fetching = _fetch;
|
||||
export interface FetchHeaders {
|
||||
get(name: string): string | null;
|
||||
}
|
||||
|
||||
export interface FetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: FetchHeaders;
|
||||
text(): Promise<string>;
|
||||
json(): Promise<any>;
|
||||
}
|
||||
|
||||
export type Fetch = (url: string, options: FetchOptions) => Promise<FetchResponse>;
|
||||
|
||||
interface Fetcher {
|
||||
name: string;
|
||||
fetch: Fetch;
|
||||
}
|
||||
|
||||
const _fetchers: Fetcher[] = [];
|
||||
try {
|
||||
_fetchers.push({
|
||||
name: 'Electron fetch',
|
||||
fetch: require('electron').net.fetch
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const nodeFetch = {
|
||||
name: 'Node fetch',
|
||||
fetch,
|
||||
};
|
||||
const useElectronFetch = workspace.getConfiguration('github-authentication').get<boolean>('useElectronFetch', true);
|
||||
if (useElectronFetch) {
|
||||
_fetchers.push(nodeFetch);
|
||||
} else {
|
||||
_fetchers.unshift(nodeFetch);
|
||||
}
|
||||
|
||||
_fetchers.push({
|
||||
name: 'Node http/s',
|
||||
fetch: nodeHTTP,
|
||||
});
|
||||
|
||||
export function createFetch(): Fetch {
|
||||
let _fetcher: Fetcher | undefined;
|
||||
return async (url, options) => {
|
||||
if (!_fetcher) {
|
||||
let firstResponse: FetchResponse | undefined;
|
||||
let firstError: any;
|
||||
for (const fetcher of _fetchers) {
|
||||
try {
|
||||
const res = await fetcher.fetch(url, options);
|
||||
if (fetcher === _fetchers[0]) {
|
||||
firstResponse = res;
|
||||
}
|
||||
if (!res.ok) {
|
||||
options.logger.info(`fetching: ${fetcher.name} failed with status: ${res.status} ${res.statusText}`);
|
||||
continue;
|
||||
}
|
||||
if (!options.expectJSON) {
|
||||
options.logger.info(`fetching: ${fetcher.name} succeeded (not JSON)`);
|
||||
_fetcher = fetcher;
|
||||
return res;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (fetcher === _fetchers[0]) {
|
||||
// Update to unconsumed response
|
||||
firstResponse = new FetchResponseImpl(
|
||||
res.status,
|
||||
res.statusText,
|
||||
res.headers,
|
||||
async () => text,
|
||||
async () => JSON.parse(text),
|
||||
);
|
||||
}
|
||||
const json = JSON.parse(text); // Verify JSON
|
||||
options.logger.info(`fetching: ${fetcher.name} succeeded (JSON)`);
|
||||
_fetcher = fetcher;
|
||||
return new FetchResponseImpl(
|
||||
res.status,
|
||||
res.statusText,
|
||||
res.headers,
|
||||
async () => text,
|
||||
async () => json,
|
||||
);
|
||||
} catch (err) {
|
||||
if (fetcher === _fetchers[0]) {
|
||||
firstError = err;
|
||||
}
|
||||
options.logger.info(`fetching: ${fetcher.name} failed with error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
_fetcher = _fetchers[0]; // Do this only once
|
||||
if (firstResponse) {
|
||||
return firstResponse;
|
||||
}
|
||||
throw firstError;
|
||||
}
|
||||
return _fetcher.fetch(url, options);
|
||||
};
|
||||
}
|
||||
|
||||
export const fetching = createFetch();
|
||||
|
||||
class FetchResponseImpl implements FetchResponse {
|
||||
public readonly ok: boolean;
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly statusText: string,
|
||||
public readonly headers: FetchHeaders,
|
||||
public readonly text: () => Promise<string>,
|
||||
public readonly json: () => Promise<any>,
|
||||
) {
|
||||
this.ok = this.status >= 200 && this.status < 300;
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeHTTP(url: string, options: FetchOptions): Promise<FetchResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { method, headers, body, signal } = options;
|
||||
const module = url.startsWith('https:') ? https : http;
|
||||
const req = module.request(url, { method, headers }, res => {
|
||||
if (signal?.aborted) {
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
reject(makeAbortError(signal));
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeFetcherResponse = new NodeFetcherResponse(req, res, signal);
|
||||
resolve(new FetchResponseImpl(
|
||||
res.statusCode || 0,
|
||||
res.statusMessage || '',
|
||||
nodeFetcherResponse.headers,
|
||||
async () => nodeFetcherResponse.text(),
|
||||
async () => nodeFetcherResponse.json(),
|
||||
));
|
||||
});
|
||||
req.setTimeout(60 * 1000); // time out after 60s of receiving no data
|
||||
req.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
class NodeFetcherResponse {
|
||||
|
||||
readonly headers: FetchHeaders;
|
||||
|
||||
constructor(
|
||||
readonly req: http.ClientRequest,
|
||||
readonly res: http.IncomingMessage,
|
||||
readonly signal: AbortSignal | undefined,
|
||||
) {
|
||||
this.headers = new class implements FetchHeaders {
|
||||
get(name: string): string | null {
|
||||
const result = res.headers[name];
|
||||
return Array.isArray(result) ? result[0] : result ?? null;
|
||||
}
|
||||
[Symbol.iterator](): Iterator<[string, string], any, undefined> {
|
||||
const keys = Object.keys(res.headers);
|
||||
let index = 0;
|
||||
return {
|
||||
next: (): IteratorResult<[string, string]> => {
|
||||
if (index >= keys.length) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
const key = keys[index++];
|
||||
return { done: false, value: [key, this.get(key)!] };
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public text(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
this.res.on('data', chunk => chunks.push(chunk));
|
||||
this.res.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
this.res.on('error', reject);
|
||||
this.signal?.addEventListener('abort', () => {
|
||||
this.res.destroy();
|
||||
this.req.destroy();
|
||||
reject(makeAbortError(this.signal!));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async json(): Promise<any> {
|
||||
const text = await this.text();
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
public async body(): Promise<NodeJS.ReadableStream | null> {
|
||||
this.signal?.addEventListener('abort', () => {
|
||||
this.res.emit('error', makeAbortError(this.signal!));
|
||||
this.res.destroy();
|
||||
this.req.destroy();
|
||||
});
|
||||
return this.res;
|
||||
}
|
||||
}
|
||||
|
||||
function makeAbortError(signal: AbortSignal): Error {
|
||||
// see https://github.com/nodejs/node/issues/38361#issuecomment-1683839467
|
||||
return signal.reason;
|
||||
}
|
||||
|
||||
141
extensions/github-authentication/src/test/node/fetch.test.ts
Normal file
141
extensions/github-authentication/src/test/node/fetch.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as http from 'http';
|
||||
import * as net from 'net';
|
||||
import { createFetch } from '../../node/fetch';
|
||||
import { Log } from '../../common/logger';
|
||||
import { AuthProviderType } from '../../github';
|
||||
|
||||
|
||||
suite('fetching', () => {
|
||||
const logger = new Log(AuthProviderType.github);
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
setup(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
server = http.createServer((req, res) => {
|
||||
const reqUrl = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const expectAgent = reqUrl.searchParams.get('expectAgent');
|
||||
const actualAgent = String(req.headers['user-agent']).toLowerCase();
|
||||
if (expectAgent && !actualAgent.includes(expectAgent)) {
|
||||
if (reqUrl.searchParams.get('error') === 'html') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html',
|
||||
'X-Client-User-Agent': actualAgent,
|
||||
});
|
||||
res.end('<html><body><h1>Bad Request</h1></body></html>');
|
||||
return;
|
||||
} else {
|
||||
res.writeHead(400, {
|
||||
'X-Client-User-Agent': actualAgent,
|
||||
});
|
||||
res.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
}
|
||||
switch (reqUrl.pathname) {
|
||||
case '/json': {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-User-Agent': actualAgent,
|
||||
});
|
||||
res.end(JSON.stringify({ message: 'Hello, world!' }));
|
||||
break;
|
||||
}
|
||||
case '/text': {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-Client-User-Agent': actualAgent,
|
||||
});
|
||||
res.end('Hello, world!');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
break;
|
||||
}
|
||||
}).listen(() => {
|
||||
port = (server.address() as net.AddressInfo).port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
teardown(async () => {
|
||||
await new Promise<unknown>((resolve) => {
|
||||
server.close(resolve);
|
||||
});
|
||||
});
|
||||
|
||||
test('should use Electron fetch', async () => {
|
||||
const res = await createFetch()(`http://localhost:${port}/json`, {
|
||||
logger,
|
||||
expectJSON: true,
|
||||
});
|
||||
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
|
||||
assert.ok(actualAgent.includes('electron'), actualAgent);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.deepStrictEqual(await res.json(), { message: 'Hello, world!' });
|
||||
});
|
||||
|
||||
test('should use Electron fetch 2', async () => {
|
||||
const res = await createFetch()(`http://localhost:${port}/text`, {
|
||||
logger,
|
||||
expectJSON: false,
|
||||
});
|
||||
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
|
||||
assert.ok(actualAgent.includes('electron'), actualAgent);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.deepStrictEqual(await res.text(), 'Hello, world!');
|
||||
});
|
||||
|
||||
test('should fall back to Node.js fetch', async () => {
|
||||
const res = await createFetch()(`http://localhost:${port}/json?expectAgent=node`, {
|
||||
logger,
|
||||
expectJSON: true,
|
||||
});
|
||||
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
|
||||
assert.strictEqual(actualAgent, 'node');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.deepStrictEqual(await res.json(), { message: 'Hello, world!' });
|
||||
});
|
||||
|
||||
test('should fall back to Node.js fetch 2', async () => {
|
||||
const res = await createFetch()(`http://localhost:${port}/json?expectAgent=node&error=html`, {
|
||||
logger,
|
||||
expectJSON: true,
|
||||
});
|
||||
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
|
||||
assert.strictEqual(actualAgent, 'node');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.deepStrictEqual(await res.json(), { message: 'Hello, world!' });
|
||||
});
|
||||
|
||||
test('should fall back to Node.js http/s', async () => {
|
||||
const res = await createFetch()(`http://localhost:${port}/json?expectAgent=undefined`, {
|
||||
logger,
|
||||
expectJSON: true,
|
||||
});
|
||||
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
|
||||
assert.strictEqual(actualAgent, 'undefined');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.deepStrictEqual(await res.json(), { message: 'Hello, world!' });
|
||||
});
|
||||
|
||||
test('should fail with first error', async () => {
|
||||
const res = await createFetch()(`http://localhost:${port}/text`, {
|
||||
logger,
|
||||
expectJSON: true, // Expect JSON but server returns text
|
||||
});
|
||||
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
|
||||
assert.ok(actualAgent.includes('electron'), actualAgent);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.deepStrictEqual(await res.text(), 'Hello, world!');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user