• 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}': ${(<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