diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 74981bbac35..2c5d4c59bab 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -63,7 +63,7 @@ namespace ts.projectSystem { readonly globalTypingsCacheLocation: string, throttleLimit: number, installTypingHost: server.ServerHost, - readonly typesRegistry = createMap(), + readonly typesRegistry = createMap>(), log?: TI.Log) { super(installTypingHost, globalTypingsCacheLocation, safeList.path, customTypesMap.path, throttleLimit, log); } diff --git a/src/harness/unittests/typingsInstaller.ts b/src/harness/unittests/typingsInstaller.ts index e76f75f45d9..cbeab8f7a97 100644 --- a/src/harness/unittests/typingsInstaller.ts +++ b/src/harness/unittests/typingsInstaller.ts @@ -1,6 +1,7 @@ /// /// /// +/// namespace ts.projectSystem { import TI = server.typingsInstaller; @@ -10,13 +11,24 @@ namespace ts.projectSystem { interface InstallerParams { globalTypingsCacheLocation?: string; throttleLimit?: number; - typesRegistry?: Map; + typesRegistry?: Map>; } - function createTypesRegistry(...list: string[]): Map { - const map = createMap(); + function createTypesRegistry(...list: string[]): Map> { + const versionMap = { + "latest": "1.3.0", + "ts2.0": "1.0.0", + "ts2.1": "1.0.0", + "ts2.2": "1.2.0", + "ts2.3": "1.3.0", + "ts2.4": "1.3.0", + "ts2.5": "1.3.0", + "ts2.6": "1.3.0", + "ts2.7": "1.3.0" + }; + const map = createMap>(); for (const l of list) { - map.set(l, undefined); + map.set(l, versionMap); } return map; } @@ -51,7 +63,7 @@ namespace ts.projectSystem { const logs: string[] = []; return { log(message) { - logs.push(message); + logs.push(message); }, finish() { return logs; @@ -1149,7 +1161,17 @@ namespace ts.projectSystem { "types-registry": "^0.1.317" }, devDependencies: { - "@types/jquery": "^3.2.16" + "@types/jquery": "^1.3.0" + } + }) + }; + const cacheLockConfig = { + path: "/a/data/package-lock.json", + content: JSON.stringify({ + dependencies: { + "@types/jquery": { + version: "1.3.0" + } } }) }; @@ -1157,7 +1179,7 @@ namespace ts.projectSystem { path: "/a/data/node_modules/@types/jquery/index.d.ts", content: "declare const $: { x: number }" }; - const host = createServerHost([file1, packageJson, timestamps, cacheConfig, jquery]); + const host = createServerHost([file1, packageJson, timestamps, cacheConfig, cacheLockConfig, jquery]); const installer = new (class extends Installer { constructor() { super(host, { typesRegistry: createTypesRegistry("jquery") }); @@ -1299,7 +1321,7 @@ namespace ts.projectSystem { content: "" }; const host = createServerHost([f, node]); - const cache = createMapFromTemplate({ node: { typingLocation: node.path, timestamp: Date.now() } }); + const cache = createMapFromTemplate({ node: { typingLocation: node.path, timestamp: Date.now(), version: new Semver(1, 0, 0, /*isPrerelease*/ false) } }); const logger = trackingLogger(); const result = JsTyping.discoverTypings(host, logger.log, [f.path], getDirectoryPath(f.path), emptySafeList, cache, { enable: true }, ["fs", "bar"]); assert.deepEqual(logger.finish(), [ @@ -1359,8 +1381,8 @@ namespace ts.projectSystem { }; const host = createServerHost([app]); const cache = createMapFromTemplate({ - node: { typingLocation: node.path, timestamp: Date.now() }, - commander: { typingLocation: commander.path, timestamp: date.getTime() } + node: { typingLocation: node.path, timestamp: Date.now(), version: new Semver(1, 0, 0, /*isPrerelease*/ false) }, + commander: { typingLocation: commander.path, timestamp: date.getTime(), version: new Semver(1, 0, 0, /*isPrerelease*/ false) } }); const logger = trackingLogger(); const result = JsTyping.discoverTypings(host, logger.log, [app.path], getDirectoryPath(app.path), emptySafeList, cache, { enable: true }, ["http", "commander"]); @@ -1433,12 +1455,22 @@ namespace ts.projectSystem { path: "/a/package.json", content: JSON.stringify({ dependencies: { commander: "1.0.0" } }) }; + const packageLockFile = { + path: "/a/cache/package-lock.json", + content: JSON.stringify({ + dependencies: { + "@types/commander": { + version: "1.0.0" + } + } + }) + }; const cachePath = "/a/cache/"; const commander = { path: cachePath + "node_modules/@types/commander/index.d.ts", content: "export let x: number" }; - const host = createServerHost([f1, packageFile]); + const host = createServerHost([f1, packageFile, packageLockFile]); let beginEvent: server.BeginInstallTypes; let endEvent: server.EndInstallTypes; const installer = new (class extends Installer { diff --git a/src/server/server.ts b/src/server/server.ts index 8e53c4d5109..6d5dfafbe8e 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -252,7 +252,7 @@ namespace ts.server { private requestMap = createMap(); // Maps operation ID to newest requestQueue entry with that ID /** We will lazily request the types registry on the first call to `isKnownTypesPackageName` and store it in `typesRegistryCache`. */ private requestedRegistry: boolean; - private typesRegistryCache: Map | undefined; + private typesRegistryCache: Map> | undefined; // This number is essentially arbitrary. Processing more than one typings request // at a time makes sense, but having too many in the pipe results in a hang diff --git a/src/server/types.ts b/src/server/types.ts index 69b1dae0d7c..161fb00a6ae 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -77,7 +77,7 @@ declare namespace ts.server { /* @internal */ export interface TypesRegistryResponse extends TypingInstallerResponse { readonly kind: EventTypesRegistry; - readonly typesRegistry: MapLike; + readonly typesRegistry: MapLike>; } export interface PackageInstalledResponse extends ProjectResponse { diff --git a/src/server/typingsInstaller/nodeTypingsInstaller.ts b/src/server/typingsInstaller/nodeTypingsInstaller.ts index 36f5adab400..e51ec68561c 100644 --- a/src/server/typingsInstaller/nodeTypingsInstaller.ts +++ b/src/server/typingsInstaller/nodeTypingsInstaller.ts @@ -41,15 +41,15 @@ namespace ts.server.typingsInstaller { } interface TypesRegistryFile { - entries: MapLike; + entries: MapLike>; } - function loadTypesRegistryFile(typesRegistryFilePath: string, host: InstallTypingHost, log: Log): Map { + function loadTypesRegistryFile(typesRegistryFilePath: string, host: InstallTypingHost, log: Log): Map> { if (!host.fileExists(typesRegistryFilePath)) { if (log.isEnabled()) { log.writeLine(`Types registry file '${typesRegistryFilePath}' does not exist`); } - return createMap(); + return createMap>(); } try { const content = JSON.parse(host.readFile(typesRegistryFilePath)); @@ -59,7 +59,7 @@ namespace ts.server.typingsInstaller { if (log.isEnabled()) { log.writeLine(`Error when loading types registry file '${typesRegistryFilePath}': ${(e).message}, ${(e).stack}`); } - return createMap(); + return createMap>(); } } @@ -77,7 +77,7 @@ namespace ts.server.typingsInstaller { export class NodeTypingsInstaller extends TypingsInstaller { private readonly nodeExecSync: ExecSync; private readonly npmPath: string; - readonly typesRegistry: Map; + readonly typesRegistry: Map>; private delayedInitializationError: InitializationFailedResponse | undefined; @@ -141,7 +141,7 @@ namespace ts.server.typingsInstaller { this.closeProject(req); break; case "typesRegistry": { - const typesRegistry: { [key: string]: void } = {}; + const typesRegistry: { [key: string]: MapLike } = {}; this.typesRegistry.forEach((value, key) => { typesRegistry[key] = value; }); diff --git a/src/server/typingsInstaller/typingsInstaller.ts b/src/server/typingsInstaller/typingsInstaller.ts index 15a8b1268ac..f3048795229 100644 --- a/src/server/typingsInstaller/typingsInstaller.ts +++ b/src/server/typingsInstaller/typingsInstaller.ts @@ -1,6 +1,7 @@ /// /// /// +/// /// /// @@ -9,6 +10,10 @@ namespace ts.server.typingsInstaller { devDependencies: MapLike; } + interface NpmLock { + dependencies: { [packageName: string]: { version: string } }; + } + export interface Log { isEnabled(): boolean; writeLine(text: string): void; @@ -104,7 +109,7 @@ namespace ts.server.typingsInstaller { private installRunCount = 1; private inFlightRequestCount = 0; - abstract readonly typesRegistry: Map; + abstract readonly typesRegistry: Map>; constructor( protected readonly installTypingHost: InstallTypingHost, @@ -217,15 +222,18 @@ namespace ts.server.typingsInstaller { } const typeDeclarationTimestamps = loadTypeDeclarationTimestampFile(timestampsFilePath || combinePaths(cacheLocation, timestampsFileName), this.installTypingHost, this.log); const packageJson = combinePaths(cacheLocation, "package.json"); + const packageLockJson = combinePaths(cacheLocation, "package-lock.json"); if (this.log.isEnabled()) { this.log.writeLine(`Trying to find '${packageJson}'...`); } - if (this.installTypingHost.fileExists(packageJson)) { + if (this.installTypingHost.fileExists(packageJson) && this.installTypingHost.fileExists(packageLockJson)) { const npmConfig = JSON.parse(this.installTypingHost.readFile(packageJson)); + const npmLock = JSON.parse(this.installTypingHost.readFile(packageLockJson)); if (this.log.isEnabled()) { this.log.writeLine(`Loaded content of '${packageJson}': ${JSON.stringify(npmConfig)}`); + this.log.writeLine(`Loaded content of '${packageLockJson}'`); } - if (npmConfig.devDependencies) { + if (npmConfig.devDependencies && npmLock.dependencies) { for (const key in npmConfig.devDependencies) { // key is @types/ const packageName = getBaseFileName(key); @@ -259,8 +267,11 @@ namespace ts.server.typingsInstaller { this.log.writeLine(`Adding entry into timestamp cache: '${key}' => '${timestamp}'`); } } + const info = getProperty(npmLock.dependencies, key); + const version = info && info.version; + const semver = Semver.parse(version); // timestamp guaranteed to not be undefined by above check - const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, timestamp: getProperty(typeDeclarationTimestamps, key) }; + const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, timestamp: getProperty(typeDeclarationTimestamps, key), version: semver }; this.packageNameToTypingLocation.set(packageName, newTyping); } } @@ -277,10 +288,6 @@ namespace ts.server.typingsInstaller { if (this.log.isEnabled()) this.log.writeLine(`'${typing}' is in missingTypingsSet - skipping...`); return false; } - if (this.packageNameToTypingLocation.get(typing) && !JsTyping.isTypingExpired(this.packageNameToTypingLocation.get(typing))) { - if (this.log.isEnabled()) this.log.writeLine(`'${typing}' already has a typing - skipping...`); - return false; - } const validationResult = JsTyping.validatePackageName(typing); if (validationResult !== JsTyping.PackageNameValidationResult.Ok) { // add typing name to missing set so we won't process it again @@ -292,8 +299,17 @@ namespace ts.server.typingsInstaller { if (this.log.isEnabled()) this.log.writeLine(`Entry for package '${typing}' does not exist in local types registry - skipping...`); return false; } + if (this.packageNameToTypingLocation.get(typing) && isTypingUpToDate(this.packageNameToTypingLocation.get(typing), this.typesRegistry.get(typing))) { + if (this.log.isEnabled()) this.log.writeLine(`'${typing}' already has an up-to-date typing - skipping...`); + return false; + } return true; }); + + function isTypingUpToDate(cachedTyping: JsTyping.CachedTyping, availableTypingVersions: MapLike) { + const availableVersion = Semver.parse(getProperty(availableTypingVersions, `ts${ts.version}`)); + return !availableVersion.greaterThan(cachedTyping.version); + } } protected ensurePackageDirectoryExists(directory: string) { @@ -364,7 +380,8 @@ namespace ts.server.typingsInstaller { } const newTimestamp = Date.now(); - const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, timestamp: newTimestamp }; + const newVersion = Semver.parse(this.typesRegistry.get(packageName)[`ts${ts.versionMajorMinor}`]); + const newTyping: JsTyping.CachedTyping = { typingLocation: typingFile, timestamp: newTimestamp, version: newVersion }; this.packageNameToTypingLocation.set(packageName, newTyping); typeDeclarationTimestamps[typesPackageName(packageName)] = newTimestamp; installedTypingFiles.push(typingFile); diff --git a/src/services/jsTyping.ts b/src/services/jsTyping.ts index 335b36952f6..75df1a7ea1b 100644 --- a/src/services/jsTyping.ts +++ b/src/services/jsTyping.ts @@ -4,6 +4,7 @@ /// /// /// +/// /* @internal */ namespace ts.JsTyping { @@ -29,6 +30,7 @@ namespace ts.JsTyping { export interface CachedTyping { typingLocation: string; timestamp: number; + version: Semver; } export function isTypingExpired(typing: JsTyping.CachedTyping | undefined) { @@ -70,7 +72,7 @@ namespace ts.JsTyping { * @param fileNames are the file names that belong to the same project * @param projectRootPath is the path to the project root directory * @param safeListPath is the path used to retrieve the safe list - * @param packageNameToTypingLocation is the map of package names to their cached typing locations and time of caching + * @param packageNameToTypingLocation is the map of package names to their cached typing locations and time of caching and versions * @param typeAcquisition is used to customize the typing acquisition process * @param compilerOptions are used as a source for typing inference */ diff --git a/src/services/semver.ts b/src/services/semver.ts new file mode 100644 index 00000000000..1f2d8e3de45 --- /dev/null +++ b/src/services/semver.ts @@ -0,0 +1,55 @@ +/* @internal */ +namespace ts { + function intOfString(str: string): number { + const n = parseInt(str, 10); + if (isNaN(n)) { + throw new Error(`Error in parseInt(${JSON.stringify(str)})`); + } + return n; + } + + export class Semver { + static parse(semver: string): Semver { + const isPrerelease = /^(.*)-next.\d+/.test(semver); + const result = Semver.tryParse(semver, isPrerelease); + if (!result) { + throw new Error(`Unexpected semver: ${semver} (isPrerelease: ${isPrerelease})`); + } + return result; + } + + static fromRaw({ major, minor, patch, isPrerelease }: Semver): Semver { + return new Semver(major, minor, patch, isPrerelease); + } + + // This must parse the output of `versionString`. + static tryParse(semver: string, isPrerelease: boolean): Semver | undefined { + // Per the semver spec : + // "A normal version number MUST take the form X.Y.Z where X, Y, and Z are non-negative integers, and MUST NOT contain leading zeroes." + const rgx = isPrerelease ? /^(\d+)\.(\d+)\.0-next.(\d+)$/ : /^(\d+)\.(\d+)\.(\d+)$/; + const match = rgx.exec(semver); + return match ? new Semver(intOfString(match[1]), intOfString(match[2]), intOfString(match[3]), isPrerelease) : undefined; + } + + constructor( + readonly major: number, readonly minor: number, readonly patch: number, + /** + * If true, this is `major.minor.0-next.patch`. + * If false, this is `major.minor.patch`. + */ + readonly isPrerelease: boolean) { } + + get versionString(): string { + return this.isPrerelease ? `${this.major}.${this.minor}.0-next.${this.patch}` : `${this.major}.${this.minor}.${this.patch}`; + } + + equals(sem: Semver): boolean { + return this.major === sem.major && this.minor === sem.minor && this.patch === sem.patch && this.isPrerelease === sem.isPrerelease; + } + + greaterThan(sem: Semver): boolean { + return this.major > sem.major || this.major === sem.major + && (this.minor > sem.minor || this.minor === sem.minor && this.patch > sem.patch); + } + } +} \ No newline at end of file diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index d73014a93a2..a0a81f0042c 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -61,6 +61,7 @@ "services.ts", "transform.ts", "transpile.ts", + "semver.ts", "shims.ts", "signatureHelp.ts", "symbolDisplay.ts",