Skip to content

Commit 97d1b93

Browse files
authored
Use fetch (microsoft#222175)
1 parent ffc4a44 commit 97d1b93

File tree

2 files changed

+182
-52
lines changed

2 files changed

+182
-52
lines changed

src/vs/base/parts/request/browser/request.ts

Lines changed: 65 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,80 +6,93 @@
66
import { bufferToStream, VSBuffer } from 'vs/base/common/buffer';
77
import { CancellationToken } from 'vs/base/common/cancellation';
88
import { canceled } from 'vs/base/common/errors';
9-
import { IRequestContext, IRequestOptions, OfflineError } from 'vs/base/parts/request/common/request';
9+
import { IHeaders, IRequestContext, IRequestOptions, OfflineError } from 'vs/base/parts/request/common/request';
1010

11-
export function request(options: IRequestOptions, token: CancellationToken): Promise<IRequestContext> {
12-
if (options.proxyAuthorization) {
13-
options.headers = {
14-
...(options.headers || {}),
15-
'Proxy-Authorization': options.proxyAuthorization
16-
};
11+
export async function request(options: IRequestOptions, token: CancellationToken): Promise<IRequestContext> {
12+
if (token.isCancellationRequested) {
13+
throw canceled();
1714
}
1815

19-
const xhr = new XMLHttpRequest();
20-
return new Promise<IRequestContext>((resolve, reject) => {
21-
22-
xhr.open(options.type || 'GET', options.url || '', true, options.user, options.password);
23-
setRequestHeaders(xhr, options);
16+
const cancellation = new AbortController();
17+
const disposable = token.onCancellationRequested(() => cancellation.abort());
18+
const signal = options.timeout ? AbortSignal.any([
19+
cancellation.signal,
20+
AbortSignal.timeout(options.timeout),
21+
]) : cancellation.signal;
2422

25-
xhr.responseType = 'arraybuffer';
26-
xhr.onerror = e => reject(
27-
navigator.onLine ? new Error(xhr.statusText && ('XHR failed: ' + xhr.statusText) || 'XHR failed') : new OfflineError()
28-
);
29-
xhr.onload = (e) => {
30-
resolve({
31-
res: {
32-
statusCode: xhr.status,
33-
headers: getResponseHeaders(xhr)
34-
},
35-
stream: bufferToStream(VSBuffer.wrap(new Uint8Array(xhr.response)))
36-
});
23+
try {
24+
const res = await fetch(options.url || '', {
25+
method: options.type || 'GET',
26+
headers: getRequestHeaders(options),
27+
body: options.data,
28+
signal,
29+
});
30+
return {
31+
res: {
32+
statusCode: res.status,
33+
headers: getResponseHeaders(res),
34+
},
35+
stream: bufferToStream(VSBuffer.wrap(new Uint8Array(await res.arrayBuffer()))),
3736
};
38-
xhr.ontimeout = e => reject(new Error(`XHR timeout: ${options.timeout}ms`));
39-
40-
if (options.timeout) {
41-
xhr.timeout = options.timeout;
37+
} catch (err) {
38+
if (!navigator.onLine) {
39+
throw new OfflineError();
4240
}
43-
44-
xhr.send(options.data);
45-
46-
// cancel
47-
token.onCancellationRequested(() => {
48-
xhr.abort();
49-
reject(canceled());
50-
});
51-
});
41+
if (err?.name === 'AbortError') {
42+
throw canceled();
43+
}
44+
if (err?.name === 'TimeoutError') {
45+
throw new Error(`Fetch timeout: ${options.timeout}ms`);
46+
}
47+
throw err;
48+
} finally {
49+
disposable.dispose();
50+
}
5251
}
5352

54-
function setRequestHeaders(xhr: XMLHttpRequest, options: IRequestOptions): void {
55-
if (options.headers) {
53+
function getRequestHeaders(options: IRequestOptions) {
54+
if (options.headers || options.user || options.password || options.proxyAuthorization) {
55+
const headers: HeadersInit = new Headers();
5656
outer: for (const k in options.headers) {
57-
switch (k) {
58-
case 'User-Agent':
59-
case 'Accept-Encoding':
60-
case 'Content-Length':
57+
switch (k.toLowerCase()) {
58+
case 'user-agent':
59+
case 'accept-encoding':
60+
case 'content-length':
6161
// unsafe headers
6262
continue outer;
6363
}
6464
const header = options.headers[k];
6565
if (typeof header === 'string') {
66-
xhr.setRequestHeader(k, header);
66+
headers.set(k, header);
6767
} else if (Array.isArray(header)) {
6868
for (const h of header) {
69-
xhr.setRequestHeader(k, h);
69+
headers.append(k, h);
7070
}
7171
}
7272
}
73+
if (options.user || options.password) {
74+
headers.set('Authorization', 'Basic ' + btoa(`${options.user || ''}:${options.password || ''}`));
75+
}
76+
if (options.proxyAuthorization) {
77+
headers.set('Proxy-Authorization', options.proxyAuthorization);
78+
}
79+
return headers;
7380
}
81+
return undefined;
7482
}
7583

76-
function getResponseHeaders(xhr: XMLHttpRequest): { [name: string]: string } {
77-
const headers: { [name: string]: string } = Object.create(null);
78-
for (const line of xhr.getAllResponseHeaders().split(/\r\n|\n|\r/g)) {
79-
if (line) {
80-
const idx = line.indexOf(':');
81-
headers[line.substr(0, idx).trim().toLowerCase()] = line.substr(idx + 1).trim();
84+
function getResponseHeaders(res: Response): IHeaders {
85+
const headers: IHeaders = Object.create(null);
86+
res.headers.forEach((value, key) => {
87+
if (headers[key]) {
88+
if (Array.isArray(headers[key])) {
89+
headers[key].push(value);
90+
} else {
91+
headers[key] = [headers[key], value];
92+
}
93+
} else {
94+
headers[key] = value;
8295
}
83-
}
96+
});
8497
return headers;
8598
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
// eslint-disable-next-line local/code-import-patterns
7+
import * as http from 'http';
8+
// eslint-disable-next-line local/code-import-patterns
9+
import { AddressInfo } from 'net';
10+
import assert from 'assert';
11+
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
12+
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
13+
import { request } from 'vs/base/parts/request/browser/request';
14+
import { streamToBuffer } from 'vs/base/common/buffer';
15+
16+
17+
suite('Request', () => {
18+
19+
let port: number;
20+
let server: http.Server;
21+
22+
setup(async () => {
23+
port = await new Promise<number>((resolvePort, rejectPort) => {
24+
server = http.createServer((req, res) => {
25+
if (req.url === '/noreply') {
26+
return; // never respond
27+
}
28+
res.setHeader('Content-Type', 'application/json');
29+
if (req.headers['echo-header']) {
30+
res.setHeader('echo-header', req.headers['echo-header']);
31+
}
32+
const data: Buffer[] = [];
33+
req.on('data', chunk => data.push(chunk));
34+
req.on('end', () => {
35+
res.end(JSON.stringify({
36+
method: req.method,
37+
url: req.url,
38+
data: Buffer.concat(data).toString()
39+
}));
40+
});
41+
}).listen(0, '127.0.0.1', () => {
42+
const address = server.address();
43+
resolvePort((address as AddressInfo).port);
44+
}).on('error', err => {
45+
rejectPort(err);
46+
});
47+
});
48+
});
49+
50+
teardown(async () => {
51+
await new Promise<void>((resolve, reject) => {
52+
server.close(err => err ? reject(err) : resolve());
53+
});
54+
});
55+
56+
test('GET', async () => {
57+
const context = await request({
58+
url: `http://127.0.0.1:${port}`,
59+
headers: {
60+
'echo-header': 'echo-value'
61+
}
62+
}, CancellationToken.None);
63+
assert.strictEqual(context.res.statusCode, 200);
64+
assert.strictEqual(context.res.headers['content-type'], 'application/json');
65+
assert.strictEqual(context.res.headers['echo-header'], 'echo-value');
66+
const buffer = await streamToBuffer(context.stream);
67+
const body = JSON.parse(buffer.toString());
68+
assert.strictEqual(body.method, 'GET');
69+
assert.strictEqual(body.url, '/');
70+
});
71+
72+
test('POST', async () => {
73+
const context = await request({
74+
type: 'POST',
75+
url: `http://127.0.0.1:${port}/postpath`,
76+
data: 'Some data',
77+
}, CancellationToken.None);
78+
assert.strictEqual(context.res.statusCode, 200);
79+
assert.strictEqual(context.res.headers['content-type'], 'application/json');
80+
const buffer = await streamToBuffer(context.stream);
81+
const body = JSON.parse(buffer.toString());
82+
assert.strictEqual(body.method, 'POST');
83+
assert.strictEqual(body.url, '/postpath');
84+
assert.strictEqual(body.data, 'Some data');
85+
});
86+
87+
test('timeout', async () => {
88+
try {
89+
await request({
90+
type: 'GET',
91+
url: `http://127.0.0.1:${port}/noreply`,
92+
timeout: 123,
93+
}, CancellationToken.None);
94+
assert.fail('Should fail with timeout');
95+
} catch (err) {
96+
assert.strictEqual(err.message, 'Fetch timeout: 123ms');
97+
}
98+
});
99+
100+
test('cancel', async () => {
101+
try {
102+
const source = new CancellationTokenSource();
103+
const res = request({
104+
type: 'GET',
105+
url: `http://127.0.0.1:${port}/noreply`,
106+
}, source.token);
107+
await new Promise(resolve => setTimeout(resolve, 100));
108+
source.cancel();
109+
await res;
110+
assert.fail('Should fail with cancellation');
111+
} catch (err) {
112+
assert.strictEqual(err.message, 'Canceled');
113+
}
114+
});
115+
116+
ensureNoDisposablesAreLeakedInTestSuite();
117+
});

0 commit comments

Comments
 (0)