3
3
4
4
'use strict' ;
5
5
6
- import { traceWarning } from '../../common/logger' ;
6
+ import * as fs from 'fs-extra' ;
7
+ import { sha256 } from 'hash.js' ;
8
+ import * as path from 'path' ;
9
+ import request from 'request' ;
10
+ import { Uri } from 'vscode' ;
11
+ import { traceError , traceInfo } from '../../common/logger' ;
12
+ import { IFileSystem , TemporaryFile } from '../../common/platform/types' ;
7
13
import { IConfigurationService , IHttpClient , WidgetCDNs } from '../../common/types' ;
8
- import { StopWatch } from '../../common/utils/stopWatch' ;
9
- import { sendTelemetryEvent } from '../../telemetry' ;
10
- import { Telemetry } from '../constants' ;
14
+ import { createDeferred , sleep } from '../../common/utils/async' ;
15
+ import { ILocalResourceUriConverter } from '../types' ;
11
16
import { IWidgetScriptSourceProvider , WidgetScriptSource } from './types' ;
12
17
13
18
// Source borrowed from https://github.com/jupyter-widgets/ipywidgets/blob/54941b7a4b54036d089652d91b39f937bde6b6cd/packages/html-manager/src/libembed-amd.ts#L33
14
19
const unpgkUrl = 'https://unpkg.com/' ;
15
20
const jsdelivrUrl = 'https://cdn.jsdelivr.net/npm/' ;
21
+
22
+ // tslint:disable: no-var-requires no-require-imports
23
+ const sanitize = require ( 'sanitize-filename' ) ;
24
+
16
25
function moduleNameToCDNUrl ( cdn : string , moduleName : string , moduleVersion : string ) {
17
26
let packageName = moduleName ;
18
27
let fileName = 'index' ; // default filename
@@ -29,6 +38,16 @@ function moduleNameToCDNUrl(cdn: string, moduleName: string, moduleVersion: stri
29
38
fileName = moduleName . substr ( index + 1 ) ;
30
39
packageName = moduleName . substr ( 0 , index ) ;
31
40
}
41
+ if ( cdn === jsdelivrUrl ) {
42
+ // Js Delivr doesn't support ^ in the version. It needs an exact version
43
+ if ( moduleVersion . startsWith ( '^' ) ) {
44
+ moduleVersion = moduleVersion . slice ( 1 ) ;
45
+ }
46
+ // Js Delivr also needs the .js file on the end.
47
+ if ( ! fileName . endsWith ( '.js' ) ) {
48
+ fileName = fileName . concat ( '.js' ) ;
49
+ }
50
+ }
32
51
return `${ cdn } ${ packageName } @${ moduleVersion } /dist/${ fileName } ` ;
33
52
}
34
53
@@ -52,40 +71,177 @@ export class CDNWidgetScriptSourceProvider implements IWidgetScriptSourceProvide
52
71
const settings = this . configurationSettings . getSettings ( undefined ) ;
53
72
return settings . datascience . widgetScriptSources ;
54
73
}
55
- public static validUrls = new Map < string , boolean > ( ) ;
74
+ private cache = new Map < string , WidgetScriptSource > ( ) ;
56
75
constructor (
57
76
private readonly configurationSettings : IConfigurationService ,
58
- private readonly httpClient : IHttpClient
77
+ private readonly httpClient : IHttpClient ,
78
+ private readonly localResourceUriConverter : ILocalResourceUriConverter ,
79
+ private readonly fileSystem : IFileSystem
59
80
) { }
60
81
public dispose ( ) {
61
- // Noop.
82
+ this . cache . clear ( ) ;
62
83
}
63
84
public async getWidgetScriptSource ( moduleName : string , moduleVersion : string ) : Promise < WidgetScriptSource > {
64
- const cdns = [ ...this . cdnProviders ] ;
65
- while ( cdns . length ) {
66
- const cdn = cdns . shift ( ) ;
67
- const cdnBaseUrl = getCDNPrefix ( cdn ) ;
68
- if ( ! cdnBaseUrl || ! cdn ) {
69
- continue ;
85
+ // First see if we already have it downloaded.
86
+ const key = this . getModuleKey ( moduleName , moduleVersion ) ;
87
+ const diskPath = path . join ( this . localResourceUriConverter . rootScriptFolder . fsPath , key , 'index.js' ) ;
88
+ let cached = this . cache . get ( key ) ;
89
+ let tempFile : TemporaryFile | undefined ;
90
+
91
+ // Might be on disk, try there first.
92
+ if ( ! cached ) {
93
+ if ( diskPath && ( await this . fileSystem . fileExists ( diskPath ) ) ) {
94
+ const scriptUri = ( await this . localResourceUriConverter . asWebviewUri ( Uri . file ( diskPath ) ) ) . toString ( ) ;
95
+ cached = { moduleName, scriptUri, source : 'cdn' } ;
96
+ this . cache . set ( key , cached ) ;
70
97
}
71
- const scriptUri = moduleNameToCDNUrl ( cdnBaseUrl , moduleName , moduleVersion ) ;
72
- const exists = await this . getUrlForWidget ( cdn , scriptUri ) ;
73
- if ( exists ) {
74
- return { moduleName, scriptUri, source : 'cdn' } ;
98
+ }
99
+
100
+ // If still not found, download it.
101
+ if ( ! cached ) {
102
+ try {
103
+ // Make sure the disk path directory exists. We'll be downloading it to there.
104
+ await this . fileSystem . createDirectory ( path . dirname ( diskPath ) ) ;
105
+
106
+ // Then get the first one that returns.
107
+ tempFile = await this . downloadFastestCDN ( moduleName , moduleVersion ) ;
108
+ if ( tempFile ) {
109
+ // Need to copy from the temporary file to our real file (note: VSC filesystem fails to copy so just use straight file system)
110
+ await fs . copyFile ( tempFile . filePath , diskPath ) ;
111
+
112
+ // Now we can generate the script URI so the local converter doesn't try to copy it.
113
+ const scriptUri = (
114
+ await this . localResourceUriConverter . asWebviewUri ( Uri . file ( diskPath ) )
115
+ ) . toString ( ) ;
116
+ cached = { moduleName, scriptUri, source : 'cdn' } ;
117
+ } else {
118
+ cached = { moduleName } ;
119
+ }
120
+ } catch ( exc ) {
121
+ traceError ( 'Error downloading from CDN: ' , exc ) ;
122
+ cached = { moduleName } ;
123
+ } finally {
124
+ if ( tempFile ) {
125
+ tempFile . dispose ( ) ;
126
+ }
127
+ }
128
+ this . cache . set ( key , cached ) ;
129
+ }
130
+
131
+ return cached ;
132
+ }
133
+
134
+ private async downloadFastestCDN ( moduleName : string , moduleVersion : string ) {
135
+ const deferred = createDeferred < TemporaryFile | undefined > ( ) ;
136
+ Promise . all (
137
+ // For each CDN, try to download it.
138
+ this . cdnProviders . map ( ( cdn ) =>
139
+ this . downloadFromCDN ( moduleName , moduleVersion , cdn ) . then ( ( t ) => {
140
+ // First one to get here wins. Meaning the first one that
141
+ // returns a valid temporary file. If a request doesn't download it will
142
+ // return undefined.
143
+ if ( ! deferred . resolved && t ) {
144
+ deferred . resolve ( t ) ;
145
+ }
146
+ } )
147
+ )
148
+ )
149
+ . then ( ( _a ) => {
150
+ // If after running all requests, we're still not resolved, then return empty.
151
+ // This would happen if both unpkg.com and jsdelivr failed.
152
+ if ( ! deferred . resolved ) {
153
+ deferred . resolve ( undefined ) ;
154
+ }
155
+ } )
156
+ . ignoreErrors ( ) ;
157
+
158
+ // Note, we only wait until one download finishes. We don't need to wait
159
+ // for everybody (hence the use of the deferred)
160
+ return deferred . promise ;
161
+ }
162
+
163
+ private async downloadFromCDN (
164
+ moduleName : string ,
165
+ moduleVersion : string ,
166
+ cdn : WidgetCDNs
167
+ ) : Promise < TemporaryFile | undefined > {
168
+ // First validate CDN
169
+ const downloadUrl = await this . generateDownloadUri ( moduleName , moduleVersion , cdn ) ;
170
+ if ( downloadUrl ) {
171
+ // Then see if we can download the file.
172
+ try {
173
+ return await this . downloadFile ( downloadUrl ) ;
174
+ } catch ( exc ) {
175
+ // Something goes wrong, just fail
75
176
}
76
177
}
77
- traceWarning ( `Widget Script not found for ${ moduleName } @${ moduleVersion } ` ) ;
78
- return { moduleName } ;
79
178
}
80
- private async getUrlForWidget ( cdn : string , url : string ) : Promise < boolean > {
81
- if ( CDNWidgetScriptSourceProvider . validUrls . has ( url ) ) {
82
- return CDNWidgetScriptSourceProvider . validUrls . get ( url ) ! ;
179
+
180
+ private async generateDownloadUri (
181
+ moduleName : string ,
182
+ moduleVersion : string ,
183
+ cdn : WidgetCDNs
184
+ ) : Promise < string | undefined > {
185
+ const cdnBaseUrl = getCDNPrefix ( cdn ) ;
186
+ if ( cdnBaseUrl ) {
187
+ return moduleNameToCDNUrl ( cdnBaseUrl , moduleName , moduleVersion ) ;
83
188
}
189
+ return undefined ;
190
+ }
191
+
192
+ private getModuleKey ( moduleName : string , moduleVersion : string ) {
193
+ return sanitize ( sha256 ( ) . update ( `${ moduleName } ${ moduleVersion } ` ) . digest ( 'hex' ) ) ;
194
+ }
84
195
85
- const stopWatch = new StopWatch ( ) ;
86
- const exists = await this . httpClient . exists ( url ) ;
87
- sendTelemetryEvent ( Telemetry . DiscoverIPyWidgetNamesCDNPerf , stopWatch . elapsedTime , { cdn, exists } ) ;
88
- CDNWidgetScriptSourceProvider . validUrls . set ( url , exists ) ;
89
- return exists ;
196
+ private handleResponse ( req : request . Request , filePath : string ) : Promise < boolean > {
197
+ const deferred = createDeferred < boolean > ( ) ;
198
+ // tslint:disable-next-line: no-any
199
+ const errorHandler = ( e : any ) => {
200
+ traceError ( 'Error downloading from CDN' , e ) ;
201
+ deferred . resolve ( false ) ;
202
+ } ;
203
+ req . on ( 'response' , ( r ) => {
204
+ if ( r . statusCode === 200 ) {
205
+ const ws = this . fileSystem . createWriteStream ( filePath ) ;
206
+ r . on ( 'error' , errorHandler )
207
+ . pipe ( ws )
208
+ . on ( 'close' , ( ) => deferred . resolve ( true ) ) ;
209
+ } else if ( r . statusCode === 429 ) {
210
+ // Special case busy. Sleep for 500 milliseconds
211
+ sleep ( 500 )
212
+ . then ( ( ) => deferred . resolve ( false ) )
213
+ . ignoreErrors ( ) ;
214
+ } else {
215
+ deferred . resolve ( false ) ;
216
+ }
217
+ } ) . on ( 'error' , errorHandler ) ;
218
+ return deferred . promise ;
219
+ }
220
+
221
+ private async downloadFile ( downloadUrl : string ) : Promise < TemporaryFile | undefined > {
222
+ // Create a temp file to download the results to
223
+ const tempFile = await this . fileSystem . createTemporaryFile ( '.js' ) ;
224
+
225
+ // Otherwise do an http get on the url. Retry at least 5 times
226
+ let retryCount = 5 ;
227
+ let success = false ;
228
+ while ( retryCount > 0 && ! success ) {
229
+ let req : request . Request ;
230
+ try {
231
+ req = await this . httpClient . downloadFile ( downloadUrl ) ;
232
+ success = await this . handleResponse ( req , tempFile . filePath ) ;
233
+ } catch ( exc ) {
234
+ traceInfo ( `Error downloading from ${ downloadUrl } : ` , exc ) ;
235
+ } finally {
236
+ retryCount -= 1 ;
237
+ }
238
+ }
239
+
240
+ // Once we make it out, return result
241
+ if ( success ) {
242
+ return tempFile ;
243
+ } else {
244
+ tempFile . dispose ( ) ;
245
+ }
90
246
}
91
247
}
0 commit comments