1import * as fs from "fs"; 2import * as path from "path"; 3 4import { 5 installNpmPackages, Log, RequestCompletedAction, TypingsInstaller, 6} from "./_namespaces/ts.server.typingsInstaller"; 7import { 8 ActionPackageInstalled, Arguments, EventTypesRegistry, findArgument, hasArgument, InitializationFailedResponse, 9 InstallTypingHost, nowString, PackageInstalledResponse, TypesRegistryResponse, TypingInstallerRequestUnion, 10 TypingInstallerResponseUnion, 11} from "./_namespaces/ts.server"; 12import { 13 combinePaths, createGetCanonicalFileName, Debug, ESMap, forEachAncestorDirectory, getDirectoryPath, getEntries, Map, 14 MapLike, normalizePath, normalizeSlashes, stringContains, sys, toPath, version, 15} from "./_namespaces/ts"; 16 17class FileLog implements Log { 18 constructor(private logFile: string | undefined) { 19 } 20 21 isEnabled = () => { 22 return typeof this.logFile === "string"; 23 }; 24 writeLine = (text: string) => { 25 if (typeof this.logFile !== "string") return; 26 27 try { 28 fs.appendFileSync(this.logFile, `[${nowString()}] ${text}${sys.newLine}`); 29 } 30 catch (e) { 31 this.logFile = undefined; 32 } 33 }; 34} 35 36/** Used if `--npmLocation` is not passed. */ 37function getDefaultNPMLocation(processName: string, validateDefaultNpmLocation: boolean, host: InstallTypingHost): string { 38 if (path.basename(processName).indexOf("node") === 0) { 39 const npmPath = path.join(path.dirname(process.argv[0]), "npm"); 40 if (!validateDefaultNpmLocation) { 41 return npmPath; 42 } 43 if (host.fileExists(npmPath)) { 44 return `"${npmPath}"`; 45 } 46 } 47 return "npm"; 48} 49 50interface TypesRegistryFile { 51 entries: MapLike<MapLike<string>>; 52} 53 54function loadTypesRegistryFile(typesRegistryFilePath: string, host: InstallTypingHost, log: Log): ESMap<string, MapLike<string>> { 55 if (!host.fileExists(typesRegistryFilePath)) { 56 if (log.isEnabled()) { 57 log.writeLine(`Types registry file '${typesRegistryFilePath}' does not exist`); 58 } 59 return new Map<string, MapLike<string>>(); 60 } 61 try { 62 const content = JSON.parse(host.readFile(typesRegistryFilePath)!) as TypesRegistryFile; 63 return new Map(getEntries(content.entries)); 64 } 65 catch (e) { 66 if (log.isEnabled()) { 67 log.writeLine(`Error when loading types registry file '${typesRegistryFilePath}': ${(e as Error).message}, ${(e as Error).stack}`); 68 } 69 return new Map<string, MapLike<string>>(); 70 } 71} 72 73const typesRegistryPackageName = "types-registry"; 74function getTypesRegistryFileLocation(globalTypingsCacheLocation: string): string { 75 return combinePaths(normalizeSlashes(globalTypingsCacheLocation), `node_modules/${typesRegistryPackageName}/index.json`); 76} 77 78interface ExecSyncOptions { 79 cwd: string; 80 encoding: "utf-8"; 81} 82type ExecSync = (command: string, options: ExecSyncOptions) => string; 83 84export class NodeTypingsInstaller extends TypingsInstaller { 85 private readonly nodeExecSync: ExecSync; 86 private readonly npmPath: string; 87 readonly typesRegistry: ESMap<string, MapLike<string>>; 88 89 private delayedInitializationError: InitializationFailedResponse | undefined; 90 91 constructor(globalTypingsCacheLocation: string, typingSafeListLocation: string, typesMapLocation: string, npmLocation: string | undefined, validateDefaultNpmLocation: boolean, throttleLimit: number, log: Log) { 92 const libDirectory = getDirectoryPath(normalizePath(sys.getExecutingFilePath())); 93 super( 94 sys, 95 globalTypingsCacheLocation, 96 typingSafeListLocation ? toPath(typingSafeListLocation, "", createGetCanonicalFileName(sys.useCaseSensitiveFileNames)) : toPath("typingSafeList.json", libDirectory, createGetCanonicalFileName(sys.useCaseSensitiveFileNames)), 97 typesMapLocation ? toPath(typesMapLocation, "", createGetCanonicalFileName(sys.useCaseSensitiveFileNames)) : toPath("typesMap.json", libDirectory, createGetCanonicalFileName(sys.useCaseSensitiveFileNames)), 98 throttleLimit, 99 log); 100 this.npmPath = npmLocation !== undefined ? npmLocation : getDefaultNPMLocation(process.argv[0], validateDefaultNpmLocation, this.installTypingHost); 101 102 // If the NPM path contains spaces and isn't wrapped in quotes, do so. 103 if (stringContains(this.npmPath, " ") && this.npmPath[0] !== `"`) { 104 this.npmPath = `"${this.npmPath}"`; 105 } 106 if (this.log.isEnabled()) { 107 this.log.writeLine(`Process id: ${process.pid}`); 108 this.log.writeLine(`NPM location: ${this.npmPath} (explicit '${Arguments.NpmLocation}' ${npmLocation === undefined ? "not " : ""} provided)`); 109 this.log.writeLine(`validateDefaultNpmLocation: ${validateDefaultNpmLocation}`); 110 } 111 ({ execSync: this.nodeExecSync } = require("child_process")); 112 113 this.ensurePackageDirectoryExists(globalTypingsCacheLocation); 114 115 try { 116 if (this.log.isEnabled()) { 117 this.log.writeLine(`Updating ${typesRegistryPackageName} npm package...`); 118 } 119 this.execSyncAndLog(`${this.npmPath} install --ignore-scripts ${typesRegistryPackageName}@${this.latestDistTag}`, { cwd: globalTypingsCacheLocation }); 120 if (this.log.isEnabled()) { 121 this.log.writeLine(`Updated ${typesRegistryPackageName} npm package`); 122 } 123 } 124 catch (e) { 125 if (this.log.isEnabled()) { 126 this.log.writeLine(`Error updating ${typesRegistryPackageName} package: ${(e as Error).message}`); 127 } 128 // store error info to report it later when it is known that server is already listening to events from typings installer 129 this.delayedInitializationError = { 130 kind: "event::initializationFailed", 131 message: (e as Error).message, 132 stack: (e as Error).stack, 133 }; 134 } 135 136 this.typesRegistry = loadTypesRegistryFile(getTypesRegistryFileLocation(globalTypingsCacheLocation), this.installTypingHost, this.log); 137 } 138 139 listen() { 140 process.on("message", (req: TypingInstallerRequestUnion) => { 141 if (this.delayedInitializationError) { 142 // report initializationFailed error 143 this.sendResponse(this.delayedInitializationError); 144 this.delayedInitializationError = undefined; 145 } 146 switch (req.kind) { 147 case "discover": 148 this.install(req); 149 break; 150 case "closeProject": 151 this.closeProject(req); 152 break; 153 case "typesRegistry": { 154 const typesRegistry: { [key: string]: MapLike<string> } = {}; 155 this.typesRegistry.forEach((value, key) => { 156 typesRegistry[key] = value; 157 }); 158 const response: TypesRegistryResponse = { kind: EventTypesRegistry, typesRegistry }; 159 this.sendResponse(response); 160 break; 161 } 162 case "installPackage": { 163 const { fileName, packageName, projectName, projectRootPath } = req; 164 const cwd = getDirectoryOfPackageJson(fileName, this.installTypingHost) || projectRootPath; 165 if (cwd) { 166 this.installWorker(-1, [packageName], cwd, success => { 167 const message = success ? `Package ${packageName} installed.` : `There was an error installing ${packageName}.`; 168 const response: PackageInstalledResponse = { kind: ActionPackageInstalled, projectName, success, message }; 169 this.sendResponse(response); 170 }); 171 } 172 else { 173 const response: PackageInstalledResponse = { kind: ActionPackageInstalled, projectName, success: false, message: "Could not determine a project root path." }; 174 this.sendResponse(response); 175 } 176 break; 177 } 178 default: 179 Debug.assertNever(req); 180 } 181 }); 182 } 183 184 protected sendResponse(response: TypingInstallerResponseUnion) { 185 if (this.log.isEnabled()) { 186 this.log.writeLine(`Sending response:\n ${JSON.stringify(response)}`); 187 } 188 process.send!(response); // TODO: GH#18217 189 if (this.log.isEnabled()) { 190 this.log.writeLine(`Response has been sent.`); 191 } 192 } 193 194 protected installWorker(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void { 195 if (this.log.isEnabled()) { 196 this.log.writeLine(`#${requestId} with arguments'${JSON.stringify(packageNames)}'.`); 197 } 198 const start = Date.now(); 199 const hasError = installNpmPackages(this.npmPath, version, packageNames, command => this.execSyncAndLog(command, { cwd })); 200 if (this.log.isEnabled()) { 201 this.log.writeLine(`npm install #${requestId} took: ${Date.now() - start} ms`); 202 } 203 onRequestCompleted(!hasError); 204 } 205 206 /** Returns 'true' in case of error. */ 207 private execSyncAndLog(command: string, options: Pick<ExecSyncOptions, "cwd">): boolean { 208 if (this.log.isEnabled()) { 209 this.log.writeLine(`Exec: ${command}`); 210 } 211 try { 212 const stdout = this.nodeExecSync(command, { ...options, encoding: "utf-8" }); 213 if (this.log.isEnabled()) { 214 this.log.writeLine(` Succeeded. stdout:${indent(sys.newLine, stdout)}`); 215 } 216 return false; 217 } 218 catch (error) { 219 const { stdout, stderr } = error; 220 this.log.writeLine(` Failed. stdout:${indent(sys.newLine, stdout)}${sys.newLine} stderr:${indent(sys.newLine, stderr)}`); 221 return true; 222 } 223 } 224} 225 226function getDirectoryOfPackageJson(fileName: string, host: InstallTypingHost): string | undefined { 227 return forEachAncestorDirectory(getDirectoryPath(fileName), directory => { 228 if (host.fileExists(combinePaths(directory, "package.json"))) { 229 return directory; 230 } 231 }); 232} 233 234const logFilePath = findArgument(Arguments.LogFile); 235const globalTypingsCacheLocation = findArgument(Arguments.GlobalCacheLocation); 236const typingSafeListLocation = findArgument(Arguments.TypingSafeListLocation); 237const typesMapLocation = findArgument(Arguments.TypesMapLocation); 238const npmLocation = findArgument(Arguments.NpmLocation); 239const validateDefaultNpmLocation = hasArgument(Arguments.ValidateDefaultNpmLocation); 240 241const log = new FileLog(logFilePath); 242if (log.isEnabled()) { 243 process.on("uncaughtException", (e: Error) => { 244 log.writeLine(`Unhandled exception: ${e} at ${e.stack}`); 245 }); 246} 247process.on("disconnect", () => { 248 if (log.isEnabled()) { 249 log.writeLine(`Parent process has exited, shutting down...`); 250 } 251 process.exit(0); 252}); 253const installer = new NodeTypingsInstaller(globalTypingsCacheLocation!, typingSafeListLocation!, typesMapLocation!, npmLocation, validateDefaultNpmLocation, /*throttleLimit*/5, log); // TODO: GH#18217 254installer.listen(); 255 256function indent(newline: string, str: string | undefined): string { 257 return str && str.length 258 ? `${newline} ` + str.replace(/\r?\n/, `${newline} `) 259 : ""; 260} 261