• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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