1namespace ts.server.typingsInstaller { 2 interface NpmConfig { 3 devDependencies: MapLike<any>; 4 } 5 6 interface NpmLock { 7 dependencies: { [packageName: string]: { version: string } }; 8 } 9 10 export interface Log { 11 isEnabled(): boolean; 12 writeLine(text: string): void; 13 } 14 15 const nullLog: Log = { 16 isEnabled: () => false, 17 writeLine: noop 18 }; 19 20 function typingToFileName(cachePath: string, packageName: string, installTypingHost: InstallTypingHost, log: Log): string | undefined { 21 try { 22 const result = resolveModuleName(packageName, combinePaths(cachePath, "index.d.ts"), { moduleResolution: ModuleResolutionKind.NodeJs }, installTypingHost); 23 return result.resolvedModule && result.resolvedModule.resolvedFileName; 24 } 25 catch (e) { 26 if (log.isEnabled()) { 27 log.writeLine(`Failed to resolve ${packageName} in folder '${cachePath}': ${(<Error>e).message}`); 28 } 29 return undefined; 30 } 31 } 32 33 /*@internal*/ 34 export function installNpmPackages(npmPath: string, tsVersion: string, packageNames: string[], install: (command: string) => boolean) { 35 let hasError = false; 36 for (let remaining = packageNames.length; remaining > 0;) { 37 const result = getNpmCommandForInstallation(npmPath, tsVersion, packageNames, remaining); 38 remaining = result.remaining; 39 hasError = install(result.command) || hasError; 40 } 41 return hasError; 42 } 43 44 /*@internal*/ 45 export function getNpmCommandForInstallation(npmPath: string, tsVersion: string, packageNames: string[], remaining: number) { 46 const sliceStart = packageNames.length - remaining; 47 let command: string, toSlice = remaining; 48 while (true) { 49 command = `${npmPath} install --ignore-scripts ${(toSlice === packageNames.length ? packageNames : packageNames.slice(sliceStart, sliceStart + toSlice)).join(" ")} --save-dev --user-agent="typesInstaller/${tsVersion}"`; 50 if (command.length < 8000) { 51 break; 52 } 53 54 toSlice = toSlice - Math.floor(toSlice / 2); 55 } 56 return { command, remaining: remaining - toSlice }; 57 } 58 59 export type RequestCompletedAction = (success: boolean) => void; 60 interface PendingRequest { 61 requestId: number; 62 packageNames: string[]; 63 cwd: string; 64 onRequestCompleted: RequestCompletedAction; 65 } 66 67 function endsWith(str: string, suffix: string, caseSensitive: boolean): boolean { 68 const expectedPos = str.length - suffix.length; 69 return expectedPos >= 0 && 70 (str.indexOf(suffix, expectedPos) === expectedPos || 71 (!caseSensitive && compareStringsCaseInsensitive(str.substr(expectedPos), suffix) === Comparison.EqualTo)); 72 } 73 74 function isPackageOrBowerJson(fileName: string, caseSensitive: boolean) { 75 return endsWith(fileName, "/package.json", caseSensitive) || endsWith(fileName, "/bower.json", caseSensitive); 76 } 77 78 function sameFiles(a: string, b: string, caseSensitive: boolean) { 79 return a === b || (!caseSensitive && compareStringsCaseInsensitive(a, b) === Comparison.EqualTo); 80 } 81 82 const enum ProjectWatcherType { 83 FileWatcher = "FileWatcher", 84 DirectoryWatcher = "DirectoryWatcher" 85 } 86 87 type ProjectWatchers = ESMap<string, FileWatcher> & { isInvoked?: boolean; }; 88 89 function getDetailWatchInfo(projectName: string, watchers: ProjectWatchers) { 90 return `Project: ${projectName} watcher already invoked: ${watchers.isInvoked}`; 91 } 92 93 export abstract class TypingsInstaller { 94 private readonly packageNameToTypingLocation = new Map<string, JsTyping.CachedTyping>(); 95 private readonly missingTypingsSet = new Set<string>(); 96 private readonly knownCachesSet = new Set<string>(); 97 private readonly projectWatchers = new Map<string, ProjectWatchers>(); 98 private safeList: JsTyping.SafeList | undefined; 99 readonly pendingRunRequests: PendingRequest[] = []; 100 private readonly toCanonicalFileName: GetCanonicalFileName; 101 private readonly globalCachePackageJsonPath: string; 102 103 private installRunCount = 1; 104 private inFlightRequestCount = 0; 105 106 abstract readonly typesRegistry: ESMap<string, MapLike<string>>; 107 /*@internal*/ 108 private readonly watchFactory: WatchFactory<string, ProjectWatchers>; 109 110 constructor( 111 protected readonly installTypingHost: InstallTypingHost, 112 private readonly globalCachePath: string, 113 private readonly safeListPath: Path, 114 private readonly typesMapLocation: Path, 115 private readonly throttleLimit: number, 116 protected readonly log = nullLog) { 117 this.toCanonicalFileName = createGetCanonicalFileName(installTypingHost.useCaseSensitiveFileNames); 118 this.globalCachePackageJsonPath = combinePaths(globalCachePath, "package.json"); 119 const isLoggingEnabled = this.log.isEnabled(); 120 if (isLoggingEnabled) { 121 this.log.writeLine(`Global cache location '${globalCachePath}', safe file path '${safeListPath}', types map path ${typesMapLocation}`); 122 } 123 this.watchFactory = getWatchFactory(this.installTypingHost as WatchFactoryHost, isLoggingEnabled ? WatchLogLevel.Verbose : WatchLogLevel.None, s => this.log.writeLine(s), getDetailWatchInfo); 124 this.processCacheLocation(this.globalCachePath); 125 } 126 127 closeProject(req: CloseProject) { 128 this.closeWatchers(req.projectName); 129 } 130 131 private closeWatchers(projectName: string): void { 132 if (this.log.isEnabled()) { 133 this.log.writeLine(`Closing file watchers for project '${projectName}'`); 134 } 135 const watchers = this.projectWatchers.get(projectName); 136 if (!watchers) { 137 if (this.log.isEnabled()) { 138 this.log.writeLine(`No watchers are registered for project '${projectName}'`); 139 } 140 return; 141 } 142 clearMap(watchers, closeFileWatcher); 143 this.projectWatchers.delete(projectName); 144 145 if (this.log.isEnabled()) { 146 this.log.writeLine(`Closing file watchers for project '${projectName}' - done.`); 147 } 148 } 149 150 install(req: DiscoverTypings) { 151 if (this.log.isEnabled()) { 152 this.log.writeLine(`Got install request ${JSON.stringify(req)}`); 153 } 154 155 // load existing typing information from the cache 156 if (req.cachePath) { 157 if (this.log.isEnabled()) { 158 this.log.writeLine(`Request specifies cache path '${req.cachePath}', loading cached information...`); 159 } 160 this.processCacheLocation(req.cachePath); 161 } 162 163 if (this.safeList === undefined) { 164 this.initializeSafeList(); 165 } 166 const discoverTypingsResult = JsTyping.discoverTypings( 167 this.installTypingHost, 168 this.log.isEnabled() ? (s => this.log.writeLine(s)) : undefined, 169 req.fileNames, 170 req.projectRootPath, 171 this.safeList!, 172 this.packageNameToTypingLocation, 173 req.typeAcquisition, 174 req.unresolvedImports, 175 this.typesRegistry); 176 177 if (this.log.isEnabled()) { 178 this.log.writeLine(`Finished typings discovery: ${JSON.stringify(discoverTypingsResult)}`); 179 } 180 181 // start watching files 182 this.watchFiles(req.projectName, discoverTypingsResult.filesToWatch, req.projectRootPath, req.watchOptions); 183 184 // install typings 185 if (discoverTypingsResult.newTypingNames.length) { 186 this.installTypings(req, req.cachePath || this.globalCachePath, discoverTypingsResult.cachedTypingPaths, discoverTypingsResult.newTypingNames); 187 } 188 else { 189 this.sendResponse(this.createSetTypings(req, discoverTypingsResult.cachedTypingPaths)); 190 if (this.log.isEnabled()) { 191 this.log.writeLine(`No new typings were requested as a result of typings discovery`); 192 } 193 } 194 } 195 196 private initializeSafeList() { 197 // Prefer the safe list from the types map if it exists 198 if (this.typesMapLocation) { 199 const safeListFromMap = JsTyping.loadTypesMap(this.installTypingHost, this.typesMapLocation); 200 if (safeListFromMap) { 201 this.log.writeLine(`Loaded safelist from types map file '${this.typesMapLocation}'`); 202 this.safeList = safeListFromMap; 203 return; 204 } 205 this.log.writeLine(`Failed to load safelist from types map file '${this.typesMapLocation}'`); 206 } 207 this.safeList = JsTyping.loadSafeList(this.installTypingHost, this.safeListPath); 208 } 209 210 private processCacheLocation(cacheLocation: string) { 211 if (this.log.isEnabled()) { 212 this.log.writeLine(`Processing cache location '${cacheLocation}'`); 213 } 214 if (this.knownCachesSet.has(cacheLocation)) { 215 if (this.log.isEnabled()) { 216 this.log.writeLine(`Cache location was already processed...`); 217 } 218 return; 219 } 220 const packageJson = combinePaths(cacheLocation, "package.json"); 221 const packageLockJson = combinePaths(cacheLocation, "package-lock.json"); 222 if (this.log.isEnabled()) { 223 this.log.writeLine(`Trying to find '${packageJson}'...`); 224 } 225 if (this.installTypingHost.fileExists(packageJson) && this.installTypingHost.fileExists(packageLockJson)) { 226 const npmConfig = <NpmConfig>JSON.parse(this.installTypingHost.readFile(packageJson)!); // TODO: GH#18217 227 const npmLock = <NpmLock>JSON.parse(this.installTypingHost.readFile(packageLockJson)!); // TODO: GH#18217 228 if (this.log.isEnabled()) { 229 this.log.writeLine(`Loaded content of '${packageJson}': ${JSON.stringify(npmConfig)}`); 230 this.log.writeLine(`Loaded content of '${packageLockJson}'`); 231 } 232 if (npmConfig.devDependencies && npmLock.dependencies) { 233 for (const key in npmConfig.devDependencies) { 234 if (!hasProperty(npmLock.dependencies, key)) { 235 // if package in package.json but not package-lock.json, skip adding to cache so it is reinstalled on next use 236 continue; 237 } 238 // key is @types/<package name> 239 const packageName = getBaseFileName(key); 240 if (!packageName) { 241 continue; 242 } 243 const typingFile = typingToFileName(cacheLocation, packageName, this.installTypingHost, this.log); 244 if (!typingFile) { 245 this.missingTypingsSet.add(packageName); 246 continue; 247 } 248 const existingTypingFile = this.packageNameToTypingLocation.get(packageName); 249 if (existingTypingFile) { 250 if (existingTypingFile.typingLocation === typingFile) { 251 continue; 252 } 253 254 if (this.log.isEnabled()) { 255 this.log.writeLine(`New typing for package ${packageName} from '${typingFile}' conflicts with existing typing file '${existingTypingFile}'`); 256 } 257 } 258 if (this.log.isEnabled()) { 259 this.log.writeLine(`Adding entry into typings cache: '${packageName}' => '${typingFile}'`); 260 } 261 const info = getProperty(npmLock.dependencies, key); 262 const version = info && info.version; 263 if (!version) { 264 continue; 265 } 266 267 const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, version: new Version(version) }; 268 this.packageNameToTypingLocation.set(packageName, newTyping); 269 } 270 } 271 } 272 if (this.log.isEnabled()) { 273 this.log.writeLine(`Finished processing cache location '${cacheLocation}'`); 274 } 275 this.knownCachesSet.add(cacheLocation); 276 } 277 278 private filterTypings(typingsToInstall: readonly string[]): readonly string[] { 279 return mapDefined(typingsToInstall, typing => { 280 const typingKey = mangleScopedPackageName(typing); 281 if (this.missingTypingsSet.has(typingKey)) { 282 if (this.log.isEnabled()) this.log.writeLine(`'${typing}':: '${typingKey}' is in missingTypingsSet - skipping...`); 283 return undefined; 284 } 285 const validationResult = JsTyping.validatePackageName(typing); 286 if (validationResult !== JsTyping.NameValidationResult.Ok) { 287 // add typing name to missing set so we won't process it again 288 this.missingTypingsSet.add(typingKey); 289 if (this.log.isEnabled()) this.log.writeLine(JsTyping.renderPackageNameValidationFailure(validationResult, typing)); 290 return undefined; 291 } 292 if (!this.typesRegistry.has(typingKey)) { 293 if (this.log.isEnabled()) this.log.writeLine(`'${typing}':: Entry for package '${typingKey}' does not exist in local types registry - skipping...`); 294 return undefined; 295 } 296 if (this.packageNameToTypingLocation.get(typingKey) && JsTyping.isTypingUpToDate(this.packageNameToTypingLocation.get(typingKey)!, this.typesRegistry.get(typingKey)!)) { 297 if (this.log.isEnabled()) this.log.writeLine(`'${typing}':: '${typingKey}' already has an up-to-date typing - skipping...`); 298 return undefined; 299 } 300 return typingKey; 301 }); 302 } 303 304 protected ensurePackageDirectoryExists(directory: string) { 305 const npmConfigPath = combinePaths(directory, "package.json"); 306 if (this.log.isEnabled()) { 307 this.log.writeLine(`Npm config file: ${npmConfigPath}`); 308 } 309 if (!this.installTypingHost.fileExists(npmConfigPath)) { 310 if (this.log.isEnabled()) { 311 this.log.writeLine(`Npm config file: '${npmConfigPath}' is missing, creating new one...`); 312 } 313 this.ensureDirectoryExists(directory, this.installTypingHost); 314 this.installTypingHost.writeFile(npmConfigPath, '{ "private": true }'); 315 } 316 } 317 318 private installTypings(req: DiscoverTypings, cachePath: string, currentlyCachedTypings: string[], typingsToInstall: string[]) { 319 if (this.log.isEnabled()) { 320 this.log.writeLine(`Installing typings ${JSON.stringify(typingsToInstall)}`); 321 } 322 const filteredTypings = this.filterTypings(typingsToInstall); 323 if (filteredTypings.length === 0) { 324 if (this.log.isEnabled()) { 325 this.log.writeLine(`All typings are known to be missing or invalid - no need to install more typings`); 326 } 327 this.sendResponse(this.createSetTypings(req, currentlyCachedTypings)); 328 return; 329 } 330 331 this.ensurePackageDirectoryExists(cachePath); 332 333 const requestId = this.installRunCount; 334 this.installRunCount++; 335 336 // send progress event 337 this.sendResponse(<BeginInstallTypes>{ 338 kind: EventBeginInstallTypes, 339 eventId: requestId, 340 // qualified explicitly to prevent occasional shadowing 341 // eslint-disable-next-line @typescript-eslint/no-unnecessary-qualifier 342 typingsInstallerVersion: ts.version, 343 projectName: req.projectName 344 }); 345 346 const scopedTypings = filteredTypings.map(typingsName); 347 this.installTypingsAsync(requestId, scopedTypings, cachePath, ok => { 348 try { 349 if (!ok) { 350 if (this.log.isEnabled()) { 351 this.log.writeLine(`install request failed, marking packages as missing to prevent repeated requests: ${JSON.stringify(filteredTypings)}`); 352 } 353 for (const typing of filteredTypings) { 354 this.missingTypingsSet.add(typing); 355 } 356 return; 357 } 358 359 // TODO: watch project directory 360 if (this.log.isEnabled()) { 361 this.log.writeLine(`Installed typings ${JSON.stringify(scopedTypings)}`); 362 } 363 const installedTypingFiles: string[] = []; 364 for (const packageName of filteredTypings) { 365 const typingFile = typingToFileName(cachePath, packageName, this.installTypingHost, this.log); 366 if (!typingFile) { 367 this.missingTypingsSet.add(packageName); 368 continue; 369 } 370 371 // packageName is guaranteed to exist in typesRegistry by filterTypings 372 const distTags = this.typesRegistry.get(packageName)!; 373 const newVersion = new Version(distTags[`ts${versionMajorMinor}`] || distTags[this.latestDistTag]); 374 const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, version: newVersion }; 375 this.packageNameToTypingLocation.set(packageName, newTyping); 376 installedTypingFiles.push(typingFile); 377 } 378 if (this.log.isEnabled()) { 379 this.log.writeLine(`Installed typing files ${JSON.stringify(installedTypingFiles)}`); 380 } 381 382 this.sendResponse(this.createSetTypings(req, currentlyCachedTypings.concat(installedTypingFiles))); 383 } 384 finally { 385 const response: EndInstallTypes = { 386 kind: EventEndInstallTypes, 387 eventId: requestId, 388 projectName: req.projectName, 389 packagesToInstall: scopedTypings, 390 installSuccess: ok, 391 // qualified explicitly to prevent occasional shadowing 392 // eslint-disable-next-line @typescript-eslint/no-unnecessary-qualifier 393 typingsInstallerVersion: ts.version 394 }; 395 this.sendResponse(response); 396 } 397 }); 398 } 399 400 private ensureDirectoryExists(directory: string, host: InstallTypingHost): void { 401 const directoryName = getDirectoryPath(directory); 402 if (!host.directoryExists(directoryName)) { 403 this.ensureDirectoryExists(directoryName, host); 404 } 405 if (!host.directoryExists(directory)) { 406 host.createDirectory(directory); 407 } 408 } 409 410 private watchFiles(projectName: string, files: string[], projectRootPath: Path, options: WatchOptions | undefined) { 411 if (!files.length) { 412 // shut down existing watchers 413 this.closeWatchers(projectName); 414 return; 415 } 416 417 let watchers = this.projectWatchers.get(projectName)!; 418 const toRemove = new Map<string, FileWatcher>(); 419 if (!watchers) { 420 watchers = new Map(); 421 this.projectWatchers.set(projectName, watchers); 422 } 423 else { 424 copyEntries(watchers, toRemove); 425 } 426 427 // handler should be invoked once for the entire set of files since it will trigger full rediscovery of typings 428 watchers.isInvoked = false; 429 430 const isLoggingEnabled = this.log.isEnabled(); 431 const createProjectWatcher = (path: string, projectWatcherType: ProjectWatcherType) => { 432 const canonicalPath = this.toCanonicalFileName(path); 433 toRemove.delete(canonicalPath); 434 if (watchers.has(canonicalPath)) { 435 return; 436 } 437 438 if (isLoggingEnabled) { 439 this.log.writeLine(`${projectWatcherType}:: Added:: WatchInfo: ${path}`); 440 } 441 const watcher = projectWatcherType === ProjectWatcherType.FileWatcher ? 442 this.watchFactory.watchFile(path, () => { 443 if (!watchers.isInvoked) { 444 watchers.isInvoked = true; 445 this.sendResponse({ projectName, kind: ActionInvalidate }); 446 } 447 }, PollingInterval.High, options, projectName, watchers) : 448 this.watchFactory.watchDirectory(path, f => { 449 if (watchers.isInvoked || !fileExtensionIs(f, Extension.Json)) { 450 return; 451 } 452 453 if (isPackageOrBowerJson(f, this.installTypingHost.useCaseSensitiveFileNames) && 454 !sameFiles(f, this.globalCachePackageJsonPath, this.installTypingHost.useCaseSensitiveFileNames)) { 455 watchers.isInvoked = true; 456 this.sendResponse({ projectName, kind: ActionInvalidate }); 457 } 458 }, WatchDirectoryFlags.Recursive, options, projectName, watchers); 459 460 watchers.set(canonicalPath, isLoggingEnabled ? { 461 close: () => { 462 this.log.writeLine(`${projectWatcherType}:: Closed:: WatchInfo: ${path}`); 463 watcher.close(); 464 } 465 } : watcher); 466 }; 467 468 // Create watches from list of files 469 for (const file of files) { 470 if (file.endsWith("/package.json") || file.endsWith("/bower.json")) { 471 // package.json or bower.json exists, watch the file to detect changes and update typings 472 createProjectWatcher(file, ProjectWatcherType.FileWatcher); 473 continue; 474 } 475 476 // path in projectRoot, watch project root 477 if (containsPath(projectRootPath, file, projectRootPath, !this.installTypingHost.useCaseSensitiveFileNames)) { 478 const subDirectory = file.indexOf(directorySeparator, projectRootPath.length + 1); 479 if (subDirectory !== -1) { 480 // Watch subDirectory 481 createProjectWatcher(file.substr(0, subDirectory), ProjectWatcherType.DirectoryWatcher); 482 } 483 else { 484 // Watch the directory itself 485 createProjectWatcher(file, ProjectWatcherType.DirectoryWatcher); 486 } 487 continue; 488 } 489 490 // path in global cache, watch global cache 491 if (containsPath(this.globalCachePath, file, projectRootPath, !this.installTypingHost.useCaseSensitiveFileNames)) { 492 createProjectWatcher(this.globalCachePath, ProjectWatcherType.DirectoryWatcher); 493 continue; 494 } 495 496 // watch node_modules or bower_components 497 createProjectWatcher(file, ProjectWatcherType.DirectoryWatcher); 498 } 499 500 // Remove unused watches 501 toRemove.forEach((watch, path) => { 502 watch.close(); 503 watchers.delete(path); 504 }); 505 } 506 507 private createSetTypings(request: DiscoverTypings, typings: string[]): SetTypings { 508 return { 509 projectName: request.projectName, 510 typeAcquisition: request.typeAcquisition, 511 compilerOptions: request.compilerOptions, 512 typings, 513 unresolvedImports: request.unresolvedImports, 514 kind: ActionSet 515 }; 516 } 517 518 private installTypingsAsync(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void { 519 this.pendingRunRequests.unshift({ requestId, packageNames, cwd, onRequestCompleted }); 520 this.executeWithThrottling(); 521 } 522 523 private executeWithThrottling() { 524 while (this.inFlightRequestCount < this.throttleLimit && this.pendingRunRequests.length) { 525 this.inFlightRequestCount++; 526 const request = this.pendingRunRequests.pop()!; 527 this.installWorker(request.requestId, request.packageNames, request.cwd, ok => { 528 this.inFlightRequestCount--; 529 request.onRequestCompleted(ok); 530 this.executeWithThrottling(); 531 }); 532 } 533 } 534 535 protected abstract installWorker(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void; 536 protected abstract sendResponse(response: SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes): void; 537 538 protected readonly latestDistTag = "latest"; 539 } 540 541 /* @internal */ 542 export function typingsName(packageName: string): string { 543 return `@types/${packageName}@ts${versionMajorMinor}`; 544 } 545} 546