Skip to content

Commit 49fb090

Browse files
authored
refactor(core): Make HttpResourceFetcher platform agnostic (#6379)
## Problem - Resource fetcher currently only works on node. This causes some implements to use got directly, some use fetch directly, some use httpResourceFetcher depending on their use case ## Solution - Make HttpResourceFetcher platform independent so it can run on node + web - Move the streaming based HttpResourceFetcher to a node specific folder --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent d55b8e1 commit 49fb090

File tree

5 files changed

+150
-85
lines changed

5 files changed

+150
-85
lines changed

packages/core/src/lambda/commands/downloadLambda.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { LaunchConfiguration, getReferencedHandlerPaths } from '../../shared/deb
1414
import { makeTemporaryToolkitFolder, fileExists, tryRemoveFolder } from '../../shared/filesystemUtilities'
1515
import * as localizedText from '../../shared/localizedText'
1616
import { getLogger } from '../../shared/logger'
17-
import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher'
17+
import { HttpResourceFetcher } from '../../shared/resourcefetcher/node/httpResourceFetcher'
1818
import { createCodeAwsSamDebugConfig } from '../../shared/sam/debugger/awsSamDebugConfiguration'
1919
import * as pathutils from '../../shared/utilities/pathUtils'
2020
import { localize } from '../../shared/utilities/vsCodeUtils'

packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts

Lines changed: 18 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,11 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import * as fs from 'fs' // eslint-disable-line no-restricted-imports
7-
import * as http from 'http'
8-
import * as https from 'https'
9-
import * as stream from 'stream'
10-
import got, { Response, RequestError, CancelError } from 'got'
11-
import urlToOptions from 'got/dist/source/core/utils/url-to-options'
12-
import Request from 'got/dist/source/core'
136
import { VSCODE_EXTENSION_ID } from '../extensions'
147
import { getLogger, Logger } from '../logger'
158
import { ResourceFetcher } from './resourcefetcher'
16-
import { Timeout, CancellationError, CancelEvent } from '../utilities/timeoutUtils'
17-
import { isCloud9 } from '../extensionUtilities'
18-
import { Headers } from 'got/dist/source/core'
19-
20-
// XXX: patched Got module for compatability with older VS Code versions (e.g. Cloud9)
21-
// `got` has also deprecated `urlToOptions`
22-
const patchedGot = got.extend({
23-
request: (url, options, callback) => {
24-
if (url.protocol === 'https:') {
25-
return https.request({ ...options, ...urlToOptions(url) }, callback)
26-
}
27-
return http.request({ ...options, ...urlToOptions(url) }, callback)
28-
},
29-
})
30-
31-
/** Promise that resolves/rejects when all streams close. Can also access streams directly. */
32-
type FetcherResult = Promise<void> & {
33-
/** Download stream piped to `fsStream`. */
34-
requestStream: Request // `got` doesn't add the correct types to 'on' for some reason
35-
/** Stream writing to the file system. */
36-
fsStream: fs.WriteStream
37-
}
9+
import { Timeout, CancelEvent } from '../utilities/timeoutUtils'
10+
import request, { RequestError } from '../request'
3811

3912
type RequestHeaders = { eTag?: string; gZip?: boolean }
4013

@@ -65,20 +38,8 @@ export class HttpResourceFetcher implements ResourceFetcher {
6538
*
6639
* @param pipeLocation Optionally pipe the download to a file system location
6740
*/
68-
public get(): Promise<string | undefined>
69-
public get(pipeLocation: string): FetcherResult
70-
public get(pipeLocation?: string): Promise<string | undefined> | FetcherResult {
41+
public get(): Promise<string | undefined> {
7142
this.logger.verbose(`downloading: ${this.logText()}`)
72-
73-
if (pipeLocation) {
74-
const result = this.pipeGetRequest(pipeLocation, this.params.timeout)
75-
result.fsStream.on('exit', () => {
76-
this.logger.verbose(`downloaded: ${this.logText()}`)
77-
})
78-
79-
return result
80-
}
81-
8243
return this.downloadRequest()
8344
}
8445

@@ -94,15 +55,15 @@ export class HttpResourceFetcher implements ResourceFetcher {
9455
public async getNewETagContent(eTag?: string): Promise<{ content?: string; eTag: string }> {
9556
const response = await this.getResponseFromGetRequest(this.params.timeout, { eTag, gZip: true })
9657

97-
const eTagResponse = response.headers.etag
58+
const eTagResponse = response.headers.get('etag')
9859
if (!eTagResponse) {
9960
throw new Error(`This URL does not support E-Tags. Cannot use this function for: ${this.url.toString()}`)
10061
}
10162

10263
// NOTE: Even with use of `gzip` encoding header, the response content is uncompressed.
10364
// Most likely due to the http request library uncompressing it for us.
104-
let contents: string | undefined = response.body.toString()
105-
if (response.statusCode === 304) {
65+
let contents: string | undefined = await response.text()
66+
if (response.status === 304) {
10667
// Explanation: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
10768
contents = undefined
10869
this.logger.verbose(`E-Tag, ${eTagResponse}, matched. No content downloaded from: ${this.url}`)
@@ -119,7 +80,8 @@ export class HttpResourceFetcher implements ResourceFetcher {
11980
private async downloadRequest(): Promise<string | undefined> {
12081
try {
12182
// HACK(?): receiving JSON as a string without `toString` makes it so we can't deserialize later
122-
const contents = (await this.getResponseFromGetRequest(this.params.timeout)).body.toString()
83+
const resp = await this.getResponseFromGetRequest(this.params.timeout)
84+
const contents = (await resp.text()).toString()
12385
if (this.params.onSuccess) {
12486
this.params.onSuccess(contents)
12587
}
@@ -128,10 +90,10 @@ export class HttpResourceFetcher implements ResourceFetcher {
12890

12991
return contents
13092
} catch (err) {
131-
const error = err as CancelError | RequestError
93+
const error = err as RequestError
13294
this.logger.verbose(
13395
`Error downloading ${this.logText()}: %s`,
134-
error.message ?? error.code ?? error.response?.statusMessage ?? error.response?.statusCode
96+
error.message ?? error.code ?? error.response.statusText ?? error.response.status
13597
)
13698
return undefined
13799
}
@@ -145,56 +107,30 @@ export class HttpResourceFetcher implements ResourceFetcher {
145107
getLogger().debug(`Download for "${this.logText()}" ${event.agent === 'user' ? 'cancelled' : 'timed out'}`)
146108
}
147109

148-
// TODO: make pipeLocation a vscode.Uri
149-
private pipeGetRequest(pipeLocation: string, timeout?: Timeout): FetcherResult {
150-
const requester = isCloud9() ? patchedGot : got
151-
const requestStream = requester.stream(this.url, { headers: this.buildRequestHeaders() })
152-
const fsStream = fs.createWriteStream(pipeLocation)
153-
154-
const done = new Promise<void>((resolve, reject) => {
155-
const pipe = stream.pipeline(requestStream, fsStream, (err) => {
156-
if (err instanceof RequestError) {
157-
return reject(Object.assign(new Error('Failed to download file'), { code: err.code }))
158-
}
159-
err ? reject(err) : resolve()
160-
})
161-
162-
const cancelListener = timeout?.token.onCancellationRequested((event) => {
163-
this.logCancellation(event)
164-
pipe.destroy(new CancellationError(event.agent))
165-
})
166-
167-
pipe.on('close', () => cancelListener?.dispose())
168-
})
169-
170-
return Object.assign(done, { requestStream, fsStream })
171-
}
172-
173-
private async getResponseFromGetRequest(timeout?: Timeout, headers?: RequestHeaders): Promise<Response<string>> {
174-
const requester = isCloud9() ? patchedGot : got
175-
const promise = requester(this.url, {
110+
private async getResponseFromGetRequest(timeout?: Timeout, headers?: RequestHeaders): Promise<Response> {
111+
const req = request.fetch('GET', this.url, {
176112
headers: this.buildRequestHeaders(headers),
177113
})
178114

179115
const cancelListener = timeout?.token.onCancellationRequested((event) => {
180116
this.logCancellation(event)
181-
promise.cancel(new CancellationError(event.agent).message)
117+
req.cancel()
182118
})
183119

184-
return promise.finally(() => cancelListener?.dispose())
120+
return req.response.finally(() => cancelListener?.dispose())
185121
}
186122

187123
private buildRequestHeaders(requestHeaders?: RequestHeaders): Headers {
188-
const headers: Headers = {}
124+
const headers = new Headers()
189125

190-
headers['User-Agent'] = VSCODE_EXTENSION_ID.awstoolkit
126+
headers.set('User-Agent', VSCODE_EXTENSION_ID.awstoolkit)
191127

192128
if (requestHeaders?.eTag !== undefined) {
193-
headers['If-None-Match'] = requestHeaders.eTag
129+
headers.set('If-None-Match', requestHeaders.eTag)
194130
}
195131

196132
if (requestHeaders?.gZip) {
197-
headers['Accept-Encoding'] = 'gzip'
133+
headers.set('Accept-Encoding', 'gzip')
198134
}
199135

200136
return headers
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as fs from 'fs' // eslint-disable-line no-restricted-imports
7+
import * as http from 'http'
8+
import * as https from 'https'
9+
import * as stream from 'stream'
10+
import got, { RequestError } from 'got'
11+
import urlToOptions from 'got/dist/source/core/utils/url-to-options'
12+
import Request from 'got/dist/source/core'
13+
import { VSCODE_EXTENSION_ID } from '../../extensions'
14+
import { getLogger, Logger } from '../../logger'
15+
import { Timeout, CancellationError, CancelEvent } from '../../utilities/timeoutUtils'
16+
import { isCloud9 } from '../../extensionUtilities'
17+
import { Headers } from 'got/dist/source/core'
18+
19+
// XXX: patched Got module for compatability with older VS Code versions (e.g. Cloud9)
20+
// `got` has also deprecated `urlToOptions`
21+
const patchedGot = got.extend({
22+
request: (url, options, callback) => {
23+
if (url.protocol === 'https:') {
24+
return https.request({ ...options, ...urlToOptions(url) }, callback)
25+
}
26+
return http.request({ ...options, ...urlToOptions(url) }, callback)
27+
},
28+
})
29+
30+
/** Promise that resolves/rejects when all streams close. Can also access streams directly. */
31+
type FetcherResult = Promise<void> & {
32+
/** Download stream piped to `fsStream`. */
33+
requestStream: Request // `got` doesn't add the correct types to 'on' for some reason
34+
/** Stream writing to the file system. */
35+
fsStream: fs.WriteStream
36+
}
37+
38+
type RequestHeaders = { eTag?: string; gZip?: boolean }
39+
40+
/**
41+
* Legacy HTTP Resource Fetcher used specifically for streaming information.
42+
* Only kept around until web streams are compatible with node streams
43+
*/
44+
export class HttpResourceFetcher {
45+
private readonly logger: Logger = getLogger()
46+
47+
/**
48+
*
49+
* @param url URL to fetch a response body from via the `get` call
50+
* @param params Additional params for the fetcher
51+
* @param {boolean} params.showUrl Whether or not to the URL in log statements.
52+
* @param {string} params.friendlyName If URL is not shown, replaces the URL with this text.
53+
* @param {function} params.onSuccess Function to execute on successful request. No effect if piping to a location.
54+
* @param {Timeout} params.timeout Timeout token to abort/cancel the request. Similar to `AbortSignal`.
55+
*/
56+
public constructor(
57+
private readonly url: string,
58+
private readonly params: {
59+
showUrl: boolean
60+
friendlyName?: string
61+
timeout?: Timeout
62+
}
63+
) {}
64+
65+
/**
66+
* Returns the contents of the resource, or undefined if the resource could not be retrieved.
67+
*
68+
* @param pipeLocation Optionally pipe the download to a file system location
69+
*/
70+
public get(pipeLocation: string): FetcherResult {
71+
this.logger.verbose(`downloading: ${this.logText()}`)
72+
73+
const result = this.pipeGetRequest(pipeLocation, this.params.timeout)
74+
result.fsStream.on('exit', () => {
75+
this.logger.verbose(`downloaded: ${this.logText()}`)
76+
})
77+
78+
return result
79+
}
80+
81+
private logText(): string {
82+
return this.params.showUrl ? this.url : (this.params.friendlyName ?? 'resource from URL')
83+
}
84+
85+
private logCancellation(event: CancelEvent) {
86+
getLogger().debug(`Download for "${this.logText()}" ${event.agent === 'user' ? 'cancelled' : 'timed out'}`)
87+
}
88+
89+
// TODO: make pipeLocation a vscode.Uri
90+
private pipeGetRequest(pipeLocation: string, timeout?: Timeout): FetcherResult {
91+
const requester = isCloud9() ? patchedGot : got
92+
const requestStream = requester.stream(this.url, { headers: this.buildRequestHeaders() })
93+
const fsStream = fs.createWriteStream(pipeLocation)
94+
95+
const done = new Promise<void>((resolve, reject) => {
96+
const pipe = stream.pipeline(requestStream, fsStream, (err) => {
97+
if (err instanceof RequestError) {
98+
return reject(Object.assign(new Error('Failed to download file'), { code: err.code }))
99+
}
100+
err ? reject(err) : resolve()
101+
})
102+
103+
const cancelListener = timeout?.token.onCancellationRequested((event) => {
104+
this.logCancellation(event)
105+
pipe.destroy(new CancellationError(event.agent))
106+
})
107+
108+
pipe.on('close', () => cancelListener?.dispose())
109+
})
110+
111+
return Object.assign(done, { requestStream, fsStream })
112+
}
113+
114+
private buildRequestHeaders(requestHeaders?: RequestHeaders): Headers {
115+
const headers: Headers = {}
116+
117+
headers['User-Agent'] = VSCODE_EXTENSION_ID.awstoolkit
118+
119+
if (requestHeaders?.eTag !== undefined) {
120+
headers['If-None-Match'] = requestHeaders.eTag
121+
}
122+
123+
if (requestHeaders?.gZip) {
124+
headers['Accept-Encoding'] = 'gzip'
125+
}
126+
127+
return headers
128+
}
129+
}

packages/core/src/shared/utilities/cliUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as vscode from 'vscode'
1111
import { getIdeProperties } from '../extensionUtilities'
1212
import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../filesystemUtilities'
1313
import { getLogger } from '../logger'
14-
import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'
14+
import { HttpResourceFetcher } from '../resourcefetcher/node/httpResourceFetcher'
1515
import { ChildProcess } from './processUtils'
1616

1717
import * as nls from 'vscode-nls'

packages/core/src/test/awsService/appBuilder/walkthrough.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { getTestWindow } from '../../shared/vscode/window'
2323
import { AwsClis, installCli } from '../../../shared/utilities/cliUtils'
2424
import { ChildProcess } from '../../../shared/utilities/processUtils'
2525
import { assertTelemetryCurried } from '../../testUtil'
26-
import { HttpResourceFetcher } from '../../../shared/resourcefetcher/httpResourceFetcher'
26+
import { HttpResourceFetcher } from '../../../shared/resourcefetcher/node/httpResourceFetcher'
2727
import { SamCliInfoInvocation } from '../../../shared/sam/cli/samCliInfo'
2828
import { CodeScansState } from '../../../codewhisperer'
2929

0 commit comments

Comments
 (0)