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}': ${(e as Error).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 req.compilerOptions); 177 178 if (this.log.isEnabled()) { 179 this.log.writeLine(`Finished typings discovery: ${JSON.stringify(discoverTypingsResult)}`); 180 } 181 182 // start watching files 183 this.watchFiles(req.projectName, discoverTypingsResult.filesToWatch, req.projectRootPath, req.watchOptions); 184 185 // install typings 186 if (discoverTypingsResult.newTypingNames.length) { 187 this.installTypings(req, req.cachePath || this.globalCachePath, discoverTypingsResult.cachedTypingPaths, discoverTypingsResult.newTypingNames); 188 } 189 else { 190 this.sendResponse(this.createSetTypings(req, discoverTypingsResult.cachedTypingPaths)); 191 if (this.log.isEnabled()) { 192 this.log.writeLine(`No new typings were requested as a result of typings discovery`); 193 } 194 } 195 } 196 197 private initializeSafeList() { 198 // Prefer the safe list from the types map if it exists 199 if (this.typesMapLocation) { 200 const safeListFromMap = JsTyping.loadTypesMap(this.installTypingHost, this.typesMapLocation); 201 if (safeListFromMap) { 202 this.log.writeLine(`Loaded safelist from types map file '${this.typesMapLocation}'`); 203 this.safeList = safeListFromMap; 204 return; 205 } 206 this.log.writeLine(`Failed to load safelist from types map file '${this.typesMapLocation}'`); 207 } 208 this.safeList = JsTyping.loadSafeList(this.installTypingHost, this.safeListPath); 209 } 210 211 private processCacheLocation(cacheLocation: string) { 212 if (this.log.isEnabled()) { 213 this.log.writeLine(`Processing cache location '${cacheLocation}'`); 214 } 215 if (this.knownCachesSet.has(cacheLocation)) { 216 if (this.log.isEnabled()) { 217 this.log.writeLine(`Cache location was already processed...`); 218 } 219 return; 220 } 221 const packageJson = combinePaths(cacheLocation, "package.json"); 222 const packageLockJson = combinePaths(cacheLocation, "package-lock.json"); 223 if (this.log.isEnabled()) { 224 this.log.writeLine(`Trying to find '${packageJson}'...`); 225 } 226 if (this.installTypingHost.fileExists(packageJson) && this.installTypingHost.fileExists(packageLockJson)) { 227 const npmConfig = JSON.parse(this.installTypingHost.readFile(packageJson)!) as NpmConfig; // TODO: GH#18217 228 const npmLock = JSON.parse(this.installTypingHost.readFile(packageLockJson)!) as NpmLock; // TODO: GH#18217 229 if (this.log.isEnabled()) { 230 this.log.writeLine(`Loaded content of '${packageJson}': ${JSON.stringify(npmConfig)}`); 231 this.log.writeLine(`Loaded content of '${packageLockJson}'`); 232 } 233 if (npmConfig.devDependencies && npmLock.dependencies) { 234 for (const key in npmConfig.devDependencies) { 235 if (!hasProperty(npmLock.dependencies, key)) { 236 // if package in package.json but not package-lock.json, skip adding to cache so it is reinstalled on next use 237 continue; 238 } 239 // key is @types/<package name> 240 const packageName = getBaseFileName(key); 241 if (!packageName) { 242 continue; 243 } 244 const typingFile = typingToFileName(cacheLocation, packageName, this.installTypingHost, this.log); 245 if (!typingFile) { 246 this.missingTypingsSet.add(packageName); 247 continue; 248 } 249 const existingTypingFile = this.packageNameToTypingLocation.get(packageName); 250 if (existingTypingFile) { 251 if (existingTypingFile.typingLocation === typingFile) { 252 continue; 253 } 254 255 if (this.log.isEnabled()) { 256 this.log.writeLine(`New typing for package ${packageName} from '${typingFile}' conflicts with existing typing file '${existingTypingFile}'`); 257 } 258 } 259 if (this.log.isEnabled()) { 260 this.log.writeLine(`Adding entry into typings cache: '${packageName}' => '${typingFile}'`); 261 } 262 const info = getProperty(npmLock.dependencies, key); 263 const version = info && info.version; 264 if (!version) { 265 continue; 266 } 267 268 const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, version: new Version(version) }; 269 this.packageNameToTypingLocation.set(packageName, newTyping); 270 } 271 } 272 } 273 if (this.log.isEnabled()) { 274 this.log.writeLine(`Finished processing cache location '${cacheLocation}'`); 275 } 276 this.knownCachesSet.add(cacheLocation); 277 } 278 279 private filterTypings(typingsToInstall: readonly string[]): readonly string[] { 280 return mapDefined(typingsToInstall, typing => { 281 const typingKey = mangleScopedPackageName(typing); 282 if (this.missingTypingsSet.has(typingKey)) { 283 if (this.log.isEnabled()) this.log.writeLine(`'${typing}':: '${typingKey}' is in missingTypingsSet - skipping...`); 284 return undefined; 285 } 286 const validationResult = JsTyping.validatePackageName(typing); 287 if (validationResult !== JsTyping.NameValidationResult.Ok) { 288 // add typing name to missing set so we won't process it again 289 this.missingTypingsSet.add(typingKey); 290 if (this.log.isEnabled()) this.log.writeLine(JsTyping.renderPackageNameValidationFailure(validationResult, typing)); 291 return undefined; 292 } 293 if (!this.typesRegistry.has(typingKey)) { 294 if (this.log.isEnabled()) this.log.writeLine(`'${typing}':: Entry for package '${typingKey}' does not exist in local types registry - skipping...`); 295 return undefined; 296 } 297 if (this.packageNameToTypingLocation.get(typingKey) && JsTyping.isTypingUpToDate(this.packageNameToTypingLocation.get(typingKey)!, this.typesRegistry.get(typingKey)!)) { 298 if (this.log.isEnabled()) this.log.writeLine(`'${typing}':: '${typingKey}' already has an up-to-date typing - skipping...`); 299 return undefined; 300 } 301 return typingKey; 302 }); 303 } 304 305 protected ensurePackageDirectoryExists(directory: string) { 306 const npmConfigPath = combinePaths(directory, "package.json"); 307 if (this.log.isEnabled()) { 308 this.log.writeLine(`Npm config file: ${npmConfigPath}`); 309 } 310 if (!this.installTypingHost.fileExists(npmConfigPath)) { 311 if (this.log.isEnabled()) { 312 this.log.writeLine(`Npm config file: '${npmConfigPath}' is missing, creating new one...`); 313 } 314 this.ensureDirectoryExists(directory, this.installTypingHost); 315 this.installTypingHost.writeFile(npmConfigPath, '{ "private": true }'); 316 } 317 } 318 319 private installTypings(req: DiscoverTypings, cachePath: string, currentlyCachedTypings: string[], typingsToInstall: string[]) { 320 if (this.log.isEnabled()) { 321 this.log.writeLine(`Installing typings ${JSON.stringify(typingsToInstall)}`); 322 } 323 const filteredTypings = this.filterTypings(typingsToInstall); 324 if (filteredTypings.length === 0) { 325 if (this.log.isEnabled()) { 326 this.log.writeLine(`All typings are known to be missing or invalid - no need to install more typings`); 327 } 328 this.sendResponse(this.createSetTypings(req, currentlyCachedTypings)); 329 return; 330 } 331 332 this.ensurePackageDirectoryExists(cachePath); 333 334 const requestId = this.installRunCount; 335 this.installRunCount++; 336 337 // send progress event 338 this.sendResponse({ 339 kind: EventBeginInstallTypes, 340 eventId: requestId, 341 // qualified explicitly to prevent occasional shadowing 342 // eslint-disable-next-line @typescript-eslint/no-unnecessary-qualifier 343 typingsInstallerVersion: ts.version, 344 projectName: req.projectName 345 } as BeginInstallTypes); 346 347 const scopedTypings = filteredTypings.map(typingsName); 348 this.installTypingsAsync(requestId, scopedTypings, cachePath, ok => { 349 try { 350 if (!ok) { 351 if (this.log.isEnabled()) { 352 this.log.writeLine(`install request failed, marking packages as missing to prevent repeated requests: ${JSON.stringify(filteredTypings)}`); 353 } 354 for (const typing of filteredTypings) { 355 this.missingTypingsSet.add(typing); 356 } 357 return; 358 } 359 360 // TODO: watch project directory 361 if (this.log.isEnabled()) { 362 this.log.writeLine(`Installed typings ${JSON.stringify(scopedTypings)}`); 363 } 364 const installedTypingFiles: string[] = []; 365 for (const packageName of filteredTypings) { 366 const typingFile = typingToFileName(cachePath, packageName, this.installTypingHost, this.log); 367 if (!typingFile) { 368 this.missingTypingsSet.add(packageName); 369 continue; 370 } 371 372 // packageName is guaranteed to exist in typesRegistry by filterTypings 373 const distTags = this.typesRegistry.get(packageName)!; 374 const newVersion = new Version(distTags[`ts${versionMajorMinor}`] || distTags[this.latestDistTag]); 375 const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, version: newVersion }; 376 this.packageNameToTypingLocation.set(packageName, newTyping); 377 installedTypingFiles.push(typingFile); 378 } 379 if (this.log.isEnabled()) { 380 this.log.writeLine(`Installed typing files ${JSON.stringify(installedTypingFiles)}`); 381 } 382 383 this.sendResponse(this.createSetTypings(req, currentlyCachedTypings.concat(installedTypingFiles))); 384 } 385 finally { 386 const response: EndInstallTypes = { 387 kind: EventEndInstallTypes, 388 eventId: requestId, 389 projectName: req.projectName, 390 packagesToInstall: scopedTypings, 391 installSuccess: ok, 392 // qualified explicitly to prevent occasional shadowing 393 // eslint-disable-next-line @typescript-eslint/no-unnecessary-qualifier 394 typingsInstallerVersion: ts.version 395 }; 396 this.sendResponse(response); 397 } 398 }); 399 } 400 401 private ensureDirectoryExists(directory: string, host: InstallTypingHost): void { 402 const directoryName = getDirectoryPath(directory); 403 if (!host.directoryExists(directoryName)) { 404 this.ensureDirectoryExists(directoryName, host); 405 } 406 if (!host.directoryExists(directory)) { 407 host.createDirectory(directory); 408 } 409 } 410 411 private watchFiles(projectName: string, files: string[], projectRootPath: Path, options: WatchOptions | undefined) { 412 if (!files.length) { 413 // shut down existing watchers 414 this.closeWatchers(projectName); 415 return; 416 } 417 418 let watchers = this.projectWatchers.get(projectName)!; 419 const toRemove = new Map<string, FileWatcher>(); 420 if (!watchers) { 421 watchers = new Map(); 422 this.projectWatchers.set(projectName, watchers); 423 } 424 else { 425 copyEntries(watchers, toRemove); 426 } 427 428 // handler should be invoked once for the entire set of files since it will trigger full rediscovery of typings 429 watchers.isInvoked = false; 430 431 const isLoggingEnabled = this.log.isEnabled(); 432 const createProjectWatcher = (path: string, projectWatcherType: ProjectWatcherType) => { 433 const canonicalPath = this.toCanonicalFileName(path); 434 toRemove.delete(canonicalPath); 435 if (watchers.has(canonicalPath)) { 436 return; 437 } 438 439 if (isLoggingEnabled) { 440 this.log.writeLine(`${projectWatcherType}:: Added:: WatchInfo: ${path}`); 441 } 442 const watcher = projectWatcherType === ProjectWatcherType.FileWatcher ? 443 this.watchFactory.watchFile(path, () => { 444 if (!watchers.isInvoked) { 445 watchers.isInvoked = true; 446 this.sendResponse({ projectName, kind: ActionInvalidate }); 447 } 448 }, PollingInterval.High, options, projectName, watchers) : 449 this.watchFactory.watchDirectory(path, f => { 450 if (watchers.isInvoked || !fileExtensionIs(f, Extension.Json)) { 451 return; 452 } 453 454 if (isPackageOrBowerJson(f, this.installTypingHost.useCaseSensitiveFileNames) && 455 !sameFiles(f, this.globalCachePackageJsonPath, this.installTypingHost.useCaseSensitiveFileNames)) { 456 watchers.isInvoked = true; 457 this.sendResponse({ projectName, kind: ActionInvalidate }); 458 } 459 }, WatchDirectoryFlags.Recursive, options, projectName, watchers); 460 461 watchers.set(canonicalPath, isLoggingEnabled ? { 462 close: () => { 463 this.log.writeLine(`${projectWatcherType}:: Closed:: WatchInfo: ${path}`); 464 watcher.close(); 465 } 466 } : watcher); 467 }; 468 469 // Create watches from list of files 470 for (const file of files) { 471 if (file.endsWith("/package.json") || file.endsWith("/bower.json")) { 472 // package.json or bower.json exists, watch the file to detect changes and update typings 473 createProjectWatcher(file, ProjectWatcherType.FileWatcher); 474 continue; 475 } 476 477 // path in projectRoot, watch project root 478 if (containsPath(projectRootPath, file, projectRootPath, !this.installTypingHost.useCaseSensitiveFileNames)) { 479 const subDirectory = file.indexOf(directorySeparator, projectRootPath.length + 1); 480 if (subDirectory !== -1) { 481 // Watch subDirectory 482 createProjectWatcher(file.substr(0, subDirectory), ProjectWatcherType.DirectoryWatcher); 483 } 484 else { 485 // Watch the directory itself 486 createProjectWatcher(file, ProjectWatcherType.DirectoryWatcher); 487 } 488 continue; 489 } 490 491 // path in global cache, watch global cache 492 if (containsPath(this.globalCachePath, file, projectRootPath, !this.installTypingHost.useCaseSensitiveFileNames)) { 493 createProjectWatcher(this.globalCachePath, ProjectWatcherType.DirectoryWatcher); 494 continue; 495 } 496 497 // watch node_modules or bower_components 498 createProjectWatcher(file, ProjectWatcherType.DirectoryWatcher); 499 } 500 501 // Remove unused watches 502 toRemove.forEach((watch, path) => { 503 watch.close(); 504 watchers.delete(path); 505 }); 506 } 507 508 private createSetTypings(request: DiscoverTypings, typings: string[]): SetTypings { 509 return { 510 projectName: request.projectName, 511 typeAcquisition: request.typeAcquisition, 512 compilerOptions: request.compilerOptions, 513 typings, 514 unresolvedImports: request.unresolvedImports, 515 kind: ActionSet 516 }; 517 } 518 519 private installTypingsAsync(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void { 520 this.pendingRunRequests.unshift({ requestId, packageNames, cwd, onRequestCompleted }); 521 this.executeWithThrottling(); 522 } 523 524 private executeWithThrottling() { 525 while (this.inFlightRequestCount < this.throttleLimit && this.pendingRunRequests.length) { 526 this.inFlightRequestCount++; 527 const request = this.pendingRunRequests.pop()!; 528 this.installWorker(request.requestId, request.packageNames, request.cwd, ok => { 529 this.inFlightRequestCount--; 530 request.onRequestCompleted(ok); 531 this.executeWithThrottling(); 532 }); 533 } 534 } 535 536 protected abstract installWorker(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void; 537 protected abstract sendResponse(response: SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes): void; 538 539 protected readonly latestDistTag = "latest"; 540 } 541 542 /* @internal */ 543 export function typingsName(packageName: string): string { 544 return `@types/${packageName}@ts${versionMajorMinor}`; 545 } 546} 547