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