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