33
44'use strict' ;
55
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' ;
713import { 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' ;
1116import { IWidgetScriptSourceProvider , WidgetScriptSource } from './types' ;
1217
1318// Source borrowed from https://github.com/jupyter-widgets/ipywidgets/blob/54941b7a4b54036d089652d91b39f937bde6b6cd/packages/html-manager/src/libembed-amd.ts#L33
1419const unpgkUrl = 'https://unpkg.com/' ;
1520const jsdelivrUrl = 'https://cdn.jsdelivr.net/npm/' ;
21+
22+ // tslint:disable: no-var-requires no-require-imports
23+ const sanitize = require ( 'sanitize-filename' ) ;
24+
1625function moduleNameToCDNUrl ( cdn : string , moduleName : string , moduleVersion : string ) {
1726 let packageName = moduleName ;
1827 let fileName = 'index' ; // default filename
@@ -29,6 +38,16 @@ function moduleNameToCDNUrl(cdn: string, moduleName: string, moduleVersion: stri
2938 fileName = moduleName . substr ( index + 1 ) ;
3039 packageName = moduleName . substr ( 0 , index ) ;
3140 }
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+ }
3251 return `${ cdn } ${ packageName } @${ moduleVersion } /dist/${ fileName } ` ;
3352}
3453
@@ -52,40 +71,177 @@ export class CDNWidgetScriptSourceProvider implements IWidgetScriptSourceProvide
5271 const settings = this . configurationSettings . getSettings ( undefined ) ;
5372 return settings . datascience . widgetScriptSources ;
5473 }
55- public static validUrls = new Map < string , boolean > ( ) ;
74+ private cache = new Map < string , WidgetScriptSource > ( ) ;
5675 constructor (
5776 private readonly configurationSettings : IConfigurationService ,
58- private readonly httpClient : IHttpClient
77+ private readonly httpClient : IHttpClient ,
78+ private readonly localResourceUriConverter : ILocalResourceUriConverter ,
79+ private readonly fileSystem : IFileSystem
5980 ) { }
6081 public dispose ( ) {
61- // Noop.
82+ this . cache . clear ( ) ;
6283 }
6384 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 ) ;
7097 }
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
75176 }
76177 }
77- traceWarning ( `Widget Script not found for ${ moduleName } @${ moduleVersion } ` ) ;
78- return { moduleName } ;
79178 }
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 ) ;
83188 }
189+ return undefined ;
190+ }
191+
192+ private getModuleKey ( moduleName : string , moduleVersion : string ) {
193+ return sanitize ( sha256 ( ) . update ( `${ moduleName } ${ moduleVersion } ` ) . digest ( 'hex' ) ) ;
194+ }
84195
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+ }
90246 }
91247}
0 commit comments