diff --git a/package-lock.json b/package-lock.json index 6e0bc60ef577f..0563aa9d71844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "typescript", "version": "6.0.0", "license": "Apache-2.0", + "dependencies": { + "validate-npm-package-name": "^6.0.2" + }, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22,8 +25,9 @@ "@types/minimist": "^1.2.5", "@types/mocha": "^10.0.10", "@types/ms": "^0.7.34", - "@types/node": "latest", + "@types/node": "^24.3.1", "@types/source-map-support": "^0.5.10", + "@types/validate-npm-package-name": "^4.0.2", "@types/which": "^3.0.4", "@typescript-eslint/rule-tester": "^8.39.1", "@typescript-eslint/type-utils": "^8.39.1", @@ -1503,10 +1507,11 @@ "dev": true }, "node_modules/@types/node": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", - "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~7.10.0" } @@ -1520,6 +1525,13 @@ "source-map": "^0.6.0" } }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/which": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", @@ -4980,6 +4992,15 @@ "node": ">=10.12.0" } }, + "node_modules/validate-npm-package-name": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", + "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", @@ -6163,9 +6184,9 @@ "dev": true }, "@types/node": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", - "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, "requires": { "undici-types": "~7.10.0" @@ -6180,6 +6201,12 @@ "source-map": "^0.6.0" } }, + "@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "dev": true + }, "@types/which": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", @@ -8604,6 +8631,11 @@ "convert-source-map": "^2.0.0" } }, + "validate-npm-package-name": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", + "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==" + }, "walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", diff --git a/package.json b/package.json index ad629ace064c5..252e9b36896cb 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,9 @@ "@types/minimist": "^1.2.5", "@types/mocha": "^10.0.10", "@types/ms": "^0.7.34", - "@types/node": "latest", + "@types/node": "^24.3.1", "@types/source-map-support": "^0.5.10", + "@types/validate-npm-package-name": "^4.0.2", "@types/which": "^3.0.4", "@typescript-eslint/rule-tester": "^8.39.1", "@typescript-eslint/type-utils": "^8.39.1", @@ -114,5 +115,8 @@ "volta": { "node": "20.1.0", "npm": "8.19.4" + }, + "dependencies": { + "validate-npm-package-name": "^6.0.2" } } diff --git a/src/typingsInstaller/nodeTypingsInstaller.ts b/src/typingsInstaller/nodeTypingsInstaller.ts index ea34149c9037b..affa2cfef7dec 100644 --- a/src/typingsInstaller/nodeTypingsInstaller.ts +++ b/src/typingsInstaller/nodeTypingsInstaller.ts @@ -1,7 +1,9 @@ -import { execSync } from "child_process"; -import * as fs from "fs"; +import { spawn } from "child_process"; +import * as fsSync from "fs"; +import * as fs from "fs/promises"; import * as path from "path"; - +import { setTimeout as nodeSetTimeout } from "timers"; +import validate from "validate-npm-package-name"; import { combinePaths, createGetCanonicalFileName, @@ -11,208 +13,1049 @@ import { normalizeSlashes, sys, toPath, - version, } from "../typescript/typescript.js"; import * as ts from "../typescript/typescript.js"; -class FileLog implements ts.server.typingsInstaller.Log { - constructor(private logFile: string | undefined) { +// Configuration interfaces +interface InstallerConfig { + readonly throttleLimit: number; + readonly registryPackageName: string; + readonly cacheTimeoutMs: number; + readonly maxRetries: number; + readonly npmTimeoutMs: number; + readonly maxCacheSize: number; +} + +interface CommandResult { + readonly success: boolean; + readonly stdout: string; + readonly stderr: string; + readonly duration: number; +} + +interface CacheEntry { + readonly timestamp: number; + readonly success: boolean; + readonly version: string; +} + +interface TypesRegistryFile { + readonly entries: MapLike>; +} + +interface InstallationMetrics { + installationsAttempted: number; + installationsSucceeded: number; + totalInstallTime: number; + registryUpdates: number; + cacheHits: number; +} + +// Custom error types +class RegistryError extends Error { + constructor( + message: string, + public readonly filePath: string, + ) { + super(message); + this.name = "RegistryError"; } +} - isEnabled = () => { - return typeof this.logFile === "string"; - }; - writeLine = (text: string) => { - if (typeof this.logFile !== "string") return; +/** Enhanced logger with structured logging support */ +class StructuredFileLog implements ts.server.typingsInstaller.Log { + private logFile: string | undefined; + + constructor(logFilePath?: string) { + this.logFile = logFilePath || undefined; + } + + isEnabled = (): boolean => this.logFile !== undefined; + + writeLine = (text: string): void => { + if (!this.logFile) return; try { - fs.appendFileSync(this.logFile, `[${ts.server.nowString()}] ${text}${sys.newLine}`); + const timestamp = ts.server.nowString(); + const logEntry = `[${timestamp}] ${text}${sys.newLine}`; + fsSync.appendFileSync(this.logFile, logEntry); } - catch { + catch (error) { + // Disable logging on error to prevent infinite loops this.logFile = undefined; + console.error("Failed to write to log file:", error); } }; + + logStructured( + level: string, + event: string, + data: Record, + ): void { + if (!this.isEnabled()) return; + + const logData = { + timestamp: new Date().toISOString(), + level, + event, + pid: process.pid, + ...data, + }; + + this.writeLine(`STRUCTURED: ${JSON.stringify(logData)}`); + } + + logMetrics(metrics: InstallationMetrics): void { + this.logStructured("INFO", "metrics", { + ...metrics, + averageInstallTime: metrics.installationsAttempted > 0 + ? metrics.totalInstallTime / metrics.installationsAttempted + : 0, + }); + } } -/** Used if `--npmLocation` is not passed. */ -function getDefaultNPMLocation(processName: string, validateDefaultNpmLocation: boolean, host: ts.server.InstallTypingHost): string { - if (path.basename(processName).indexOf("node") === 0) { - const npmPath = path.join(path.dirname(process.argv[0]), "npm"); - if (!validateDefaultNpmLocation) { - return npmPath; +/** NPM client abstraction for better testability and error handling */ +class NpmClient { + private readonly config: InstallerConfig; + private readonly log: StructuredFileLog; + + constructor( + private readonly npmPath: string, + config: InstallerConfig, + log: StructuredFileLog, + ) { + this.config = config; + this.log = log; + } + + static create( + processName: string, + npmLocation: string | undefined, + validateDefault: boolean, + host: ts.server.InstallTypingHost, + config: InstallerConfig, + log: StructuredFileLog, + ): NpmClient { + const npmPath = npmLocation || + NpmClient.getDefaultNPMLocation(processName, validateDefault, host); + const quotedPath = npmPath.includes(" ") && !npmPath.startsWith('"') + ? `"${npmPath}"` + : npmPath; + + return new NpmClient(quotedPath, config, log); + } + + private static getDefaultNPMLocation( + processName: string, + validate: boolean, + host: ts.server.InstallTypingHost, + ): string { + if (path.basename(processName).indexOf("node") === 0) { + const npmPath = path.join(path.dirname(process.argv[0]), "npm"); + if (!validate || host.fileExists(npmPath)) { + return npmPath; + } } - if (host.fileExists(npmPath)) { - return `"${npmPath}"`; + return "npm"; + } + + async install(packages: readonly string[], cwd: string): Promise { + const sanitizedPackages = packages.map(pkg => this.sanitizePackageName(pkg)); + const command = [ + this.npmPath, + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--silent", + ...sanitizedPackages, + ]; + + const result = await this.executeCommand(command, { cwd }); + return result.success; + } + + async updatePackage(packageName: string, cwd: string): Promise { + const sanitizedName = this.sanitizePackageName(packageName); + const command = [ + this.npmPath, + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--silent", + `${sanitizedName}@latest`, + ]; + + const result = await this.executeCommand(command, { cwd }); + return result.success; + } + + private sanitizePackageName(name: string): string { + const result = validate(name); + + if (!result.validForNewPackages && !result.validForOldPackages) { + throw new Error(`Invalid package name: ${name}`); } + + return name; } - return "npm"; -} -interface TypesRegistryFile { - entries: MapLike>; + private async executeCommand( + command: readonly string[], + options: { cwd: string; }, + ): Promise { + const startTime = Date.now(); + const commandString = command.join(" "); + + this.log.logStructured("DEBUG", "npm_command_start", { + command: commandString, + cwd: options.cwd, + }); + + return new Promise(resolve => { + const child = spawn(command[0], command.slice(1), { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout: this.config.npmTimeoutMs, + }); + + let stdout = ""; + let stderr = ""; + + if (child.stdout) { + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + } + + if (child.stderr) { + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + } + + child.on("close", (code: number | undefined) => { + const duration = Date.now() - startTime; + const success = code === 0; + + const result: CommandResult = { + success, + stdout, + stderr, + duration, + }; + + this.log.logStructured( + success ? "DEBUG" : "ERROR", + "npm_command_complete", + { + command: commandString, + success, + duration, + code, + stdout: success ? stdout : undefined, + stderr: success ? undefined : stderr, + }, + ); + + if (!success && code !== undefined) { + this.log.writeLine(`NPM command failed: ${commandString}`); + this.log.writeLine(` Exit code: ${code}`); + this.log.writeLine(` stderr: ${stderr}`); + } + + resolve(result); + }); + + child.on("error", (error: Error) => { + const duration = Date.now() - startTime; + this.log.logStructured("ERROR", "npm_command_error", { + command: commandString, + error: error.message, + duration, + }); + + resolve({ + success: false, + stdout, + stderr: error.message, + duration, + }); + }); + }); + } } -function loadTypesRegistryFile(typesRegistryFilePath: string, host: ts.server.InstallTypingHost, log: ts.server.typingsInstaller.Log): Map> { - if (!host.fileExists(typesRegistryFilePath)) { - if (log.isEnabled()) { - log.writeLine(`Types registry file '${typesRegistryFilePath}' does not exist`); +/** Types registry management with caching and error recovery */ +class TypingsRegistry { + private registry: Map> = new Map(); + private lastLoadTime = 0; + + constructor( + private readonly config: InstallerConfig, + private readonly log: StructuredFileLog, + ) {} + + async load( + filePath: string, + host: ts.server.InstallTypingHost, + maxAge: number = this.config.cacheTimeoutMs, + ): Promise>> { + const now = Date.now(); + + // Return cached registry if still valid + if (this.registry.size > 0 && now - this.lastLoadTime < maxAge) { + this.log.logStructured("DEBUG", "registry_cache_hit", { filePath }); + return this.registry; + } + + try { + this.registry = await this.loadFromFile(filePath, host); + this.lastLoadTime = now; + + this.log.logStructured("INFO", "registry_loaded", { + filePath, + entriesCount: this.registry.size, + }); + + return this.registry; + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + this.log.logStructured("ERROR", "registry_load_failed", { + filePath, + error: errorMessage, + }); + + // Return existing registry if available, otherwise empty + return this.registry.size > 0 ? this.registry : new Map(); } - return new Map>(); } - try { - const content = JSON.parse(host.readFile(typesRegistryFilePath)!) as TypesRegistryFile; - return new Map(Object.entries(content.entries)); + + private async loadFromFile( + filePath: string, + host: ts.server.InstallTypingHost, + ): Promise>> { + if (!host.fileExists(filePath)) { + throw new RegistryError( + `Registry file does not exist: ${filePath}`, + filePath, + ); + } + + try { + const content = host.readFile(filePath); + if (!content) { + throw new RegistryError( + `Failed to read registry file: ${filePath}`, + filePath, + ); + } + + const parsed = JSON.parse(content) as TypesRegistryFile; + + if (!parsed.entries || typeof parsed.entries !== "object") { + throw new RegistryError( + `Invalid registry file format: ${filePath}`, + filePath, + ); + } + + return new Map(Object.entries(parsed.entries)); + } + catch (error) { + if (error instanceof RegistryError) { + throw error; + } + + const message = error instanceof Error + ? error.message + : "Unknown parsing error"; + throw new RegistryError( + `Failed to parse registry file: ${message}`, + filePath, + ); + } } - catch (e) { - if (log.isEnabled()) { - log.writeLine(`Error when loading types registry file '${typesRegistryFilePath}': ${(e as Error).message}, ${(e as Error).stack}`); + + async update( + globalCache: string, + npmClient: NpmClient, + packageName: string, + ): Promise { + this.log.logStructured("INFO", "registry_update_start", { + globalCache, + packageName, + }); + + const success = await npmClient.updatePackage(packageName, globalCache); + + if (!success) { + throw new Error( + `Failed to update registry package: ${packageName}`, + ); } - return new Map>(); + + // Clear cache to force reload + this.registry.clear(); + this.lastLoadTime = 0; + + this.log.logStructured("INFO", "registry_update_complete", { + packageName, + }); } -} -const typesRegistryPackageName = "types-registry"; -function getTypesRegistryFileLocation(globalTypingsCacheLocation: string): string { - return combinePaths(normalizeSlashes(globalTypingsCacheLocation), `node_modules/${typesRegistryPackageName}/index.json`); + getPackageInfo(packageName: string): MapLike | undefined { + return this.registry.get(packageName); + } } -interface ExecSyncOptions { - cwd: string; - encoding: "utf-8"; +/** Installation cache with LRU eviction */ +class InstallationCache { + private readonly cache = new Map(); + private readonly accessOrder = new Set(); + + constructor(private readonly config: InstallerConfig) {} + + isRecentlyInstalled(packageName: string): boolean { + const entry = this.cache.get(packageName); + + if (!entry) { + return false; + } + + const isExpired = Date.now() - entry.timestamp > this.config.cacheTimeoutMs; + + if (isExpired) { + this.cache.delete(packageName); + this.accessOrder.delete(packageName); + return false; + } + + // Update access order + this.accessOrder.delete(packageName); + this.accessOrder.add(packageName); + + return entry.success; + } + + recordInstallation( + packageName: string, + success: boolean, + version = "latest", + ): void { + // Ensure cache size limit + if (this.cache.size >= this.config.maxCacheSize) { + this.evictOldest(); + } + + const entry: CacheEntry = { + timestamp: Date.now(), + success, + version, + }; + + this.cache.set(packageName, entry); + this.accessOrder.delete(packageName); + this.accessOrder.add(packageName); + } + + private evictOldest(): void { + const oldest = this.accessOrder.values().next().value; + if (oldest) { + this.cache.delete(oldest); + this.accessOrder.delete(oldest); + } + } + + getCacheStats(): { size: number; maxSize: number; } { + return { + size: this.cache.size, + maxSize: this.config.maxCacheSize, + }; + } + + clear(): void { + this.cache.clear(); + this.accessOrder.clear(); + } } +/** Main typings installer with improved architecture */ class NodeTypingsInstaller extends ts.server.typingsInstaller.TypingsInstaller { - private readonly npmPath: string; - readonly typesRegistry: Map>; + private readonly npmClient: NpmClient; + private readonly typingsRegistryManager: TypingsRegistry; + private readonly installationCache: InstallationCache; + private readonly config: InstallerConfig; + private readonly metrics: InstallationMetrics; + private delayedInitializationError: + | ts.server.InitializationFailedResponse + | undefined; + + // Implement the abstract typesRegistry property from base class + readonly typesRegistry: Map> = new Map(); + + constructor( + globalTypingsCache: string, + log: StructuredFileLog, + safeListLocation?: string, + typesMapLocation?: string, + npmLocation?: string, + validateDefaultNpmLocation = true, + config: Partial = {}, + ) { + const libDirectory = getDirectoryPath( + normalizePath(sys.getExecutingFilePath()), + ); + + // Create canonical file name function + const getCanonicalFileName = createGetCanonicalFileName( + sys.useCaseSensitiveFileNames, + ); + + // Resolve paths + const resolvedSafeListLocation = safeListLocation + ? toPath(safeListLocation, "", getCanonicalFileName) + : toPath("typingSafeList.json", libDirectory, getCanonicalFileName); + + const resolvedTypesMapLocation = typesMapLocation + ? toPath(typesMapLocation, "", getCanonicalFileName) + : toPath("typesMap.json", libDirectory, getCanonicalFileName); - private delayedInitializationError: ts.server.InitializationFailedResponse | undefined; + // Initialize with validated config + const validatedConfig = NodeTypingsInstaller.validateConfig(config); - constructor(globalTypingsCacheLocation: string, typingSafeListLocation: string, typesMapLocation: string, npmLocation: string | undefined, validateDefaultNpmLocation: boolean, throttleLimit: number, log: ts.server.typingsInstaller.Log) { - const libDirectory = getDirectoryPath(normalizePath(sys.getExecutingFilePath())); super( sys, - globalTypingsCacheLocation, - typingSafeListLocation ? toPath(typingSafeListLocation, "", createGetCanonicalFileName(sys.useCaseSensitiveFileNames)) : toPath("typingSafeList.json", libDirectory, createGetCanonicalFileName(sys.useCaseSensitiveFileNames)), - typesMapLocation ? toPath(typesMapLocation, "", createGetCanonicalFileName(sys.useCaseSensitiveFileNames)) : toPath("typesMap.json", libDirectory, createGetCanonicalFileName(sys.useCaseSensitiveFileNames)), - throttleLimit, + globalTypingsCache, + resolvedSafeListLocation, + resolvedTypesMapLocation, + validatedConfig.throttleLimit, log, ); - this.npmPath = npmLocation !== undefined ? npmLocation : getDefaultNPMLocation(process.argv[0], validateDefaultNpmLocation, this.installTypingHost); - // If the NPM path contains spaces and isn't wrapped in quotes, do so. - if (this.npmPath.includes(" ") && this.npmPath[0] !== `"`) { - this.npmPath = `"${this.npmPath}"`; - } - if (this.log.isEnabled()) { - this.log.writeLine(`Process id: ${process.pid}`); - this.log.writeLine(`NPM location: ${this.npmPath} (explicit '${ts.server.Arguments.NpmLocation}' ${npmLocation === undefined ? "not " : ""} provided)`); - this.log.writeLine(`validateDefaultNpmLocation: ${validateDefaultNpmLocation}`); - } + this.config = validatedConfig; + this.metrics = { + installationsAttempted: 0, + installationsSucceeded: 0, + totalInstallTime: 0, + registryUpdates: 0, + cacheHits: 0, + }; - this.ensurePackageDirectoryExists(globalTypingsCacheLocation); + // Initialize components + this.npmClient = NpmClient.create( + process.argv[0], + npmLocation, + validateDefaultNpmLocation, + this.installTypingHost, + this.config, + log, + ); - try { - if (this.log.isEnabled()) { - this.log.writeLine(`Updating ${typesRegistryPackageName} npm package...`); - } - this.execSyncAndLog(`${this.npmPath} install --ignore-scripts ${typesRegistryPackageName}@${this.latestDistTag}`, { cwd: globalTypingsCacheLocation }); - if (this.log.isEnabled()) { - this.log.writeLine(`Updated ${typesRegistryPackageName} npm package`); - } + this.typingsRegistryManager = new TypingsRegistry(this.config, log); + this.installationCache = new InstallationCache(this.config); + + // Log initialization + if (log.isEnabled()) { + log.logStructured("INFO", "installer_initialized", { + pid: process.pid, + globalCache: globalTypingsCache, + config: this.config, + validateDefaultNpm: validateDefaultNpmLocation, + }); } - catch (e) { - if (this.log.isEnabled()) { - this.log.writeLine(`Error updating ${typesRegistryPackageName} package: ${(e as Error).message}`); - } - // store error info to report it later when it is known that server is already listening to events from typings installer + + // Initialize asynchronously + this.initializeAsync(globalTypingsCache, log).catch(error => { + const errorMessage = error instanceof Error + ? error.message + : "Unknown initialization error"; + const errorStack = error instanceof Error ? error.stack : undefined; + + log.logStructured("ERROR", "initialization_failed", { + error: errorMessage, + stack: errorStack, + }); + this.delayedInitializationError = { kind: "event::initializationFailed", - message: (e as Error).message, - stack: (e as Error).stack, + message: errorMessage, + stack: errorStack, }; + }); + } + + private static validateConfig( + partial: Partial, + ): InstallerConfig { + return { + throttleLimit: Math.max( + 1, + Math.min(20, partial.throttleLimit ?? 5), + ), + registryPackageName: partial.registryPackageName ?? "types-registry", + cacheTimeoutMs: Math.max( + 60000, + partial.cacheTimeoutMs ?? 24 * 60 * 60 * 1000, + ), // min 1 minute + maxRetries: Math.max(1, Math.min(5, partial.maxRetries ?? 3)), + npmTimeoutMs: Math.max( + 30000, + partial.npmTimeoutMs ?? 5 * 60 * 1000, + ), // min 30 seconds + maxCacheSize: Math.max( + 100, + Math.min(10000, partial.maxCacheSize ?? 1000), + ), + }; + } + + private async initializeAsync( + globalTypingsCache: string, + log: StructuredFileLog, + ): Promise { + // Ensure package directory exists + await this.createDirectoryIfNotExists(globalTypingsCache); + + // Update types registry + await this.retryOperation( + () => + this.typingsRegistryManager.update( + globalTypingsCache, + this.npmClient, + this.config.registryPackageName, + ), + this.config.maxRetries, + ); + + this.metrics.registryUpdates++; + + // Load registry + const registryPath = this.getTypesRegistryFileLocation(globalTypingsCache); + const loadedRegistry = await this.typingsRegistryManager.load( + registryPath, + this.installTypingHost, + ); + + // Update the base class typesRegistry property + this.typesRegistry.clear(); + loadedRegistry.forEach((value, key) => { + this.typesRegistry.set(key, value); + }); + + log.logStructured("INFO", "initialization_complete", { + registryPackage: this.config.registryPackageName, + }); + } + + private async createDirectoryIfNotExists(dirPath: string): Promise { + try { + await fs.access(dirPath); } + catch { + await fs.mkdir(dirPath, { recursive: true }); + } + } + + private async retryOperation( + operation: () => Promise, + maxRetries: number, + delay = 1000, + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } + catch (error) { + lastError = error instanceof Error ? error : new Error("Unknown error"); + + if (attempt === maxRetries) { + break; + } + + // Exponential backoff + const backoffDelay = delay * Math.pow(2, attempt - 1); + await new Promise(resolve => { + const timeoutId = nodeSetTimeout(() => { + resolve(); + }, backoffDelay); + timeoutId.unref(); + }); + + if (this.log.isEnabled()) { + (this.log as StructuredFileLog).logStructured( + "WARN", + "operation_retry", + { + attempt, + maxRetries, + error: lastError.message, + nextDelay: backoffDelay, + }, + ); + } + } + } + + throw lastError || new Error("Operation failed after retries"); + } - this.typesRegistry = loadTypesRegistryFile(getTypesRegistryFileLocation(globalTypingsCacheLocation), this.installTypingHost, this.log); + private getTypesRegistryFileLocation(globalCache: string): string { + return combinePaths( + normalizeSlashes(globalCache), + `node_modules/${this.config.registryPackageName}/index.json`, + ); } override handleRequest(req: ts.server.TypingInstallerRequestUnion): void { + // Handle delayed initialization error if (this.delayedInitializationError) { - // report initializationFailed error this.sendResponse(this.delayedInitializationError); this.delayedInitializationError = undefined; + return; + } + + // Log metrics periodically + if ( + this.metrics.installationsAttempted % 10 === 0 && + this.log.isEnabled() + ) { + (this.log as StructuredFileLog).logMetrics(this.metrics); } + super.handleRequest(req); } - protected sendResponse(response: ts.server.TypingInstallerResponseUnion): void { + override sendResponse( + response: ts.server.TypingInstallerResponseUnion, + ): void { if (this.log.isEnabled()) { - this.log.writeLine(`Sending response:${ts.server.stringifyIndented(response)}`); + (this.log as StructuredFileLog).logStructured( + "DEBUG", + "response_sent", + { + responseKind: response.kind, + }, + ); } - process.send!(response); // TODO: GH#18217 - if (this.log.isEnabled()) { - this.log.writeLine(`Response has been sent.`); + + if (process.send) { + process.send(response); } } - protected installWorker(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: ts.server.typingsInstaller.RequestCompletedAction): void { - if (this.log.isEnabled()) { - this.log.writeLine(`#${requestId} with cwd: ${cwd} arguments: ${JSON.stringify(packageNames)}`); - } - const start = Date.now(); - const hasError = ts.server.typingsInstaller.installNpmPackages(this.npmPath, version, packageNames, command => this.execSyncAndLog(command, { cwd })); - if (this.log.isEnabled()) { - this.log.writeLine(`npm install #${requestId} took: ${Date.now() - start} ms`); - } - onRequestCompleted(!hasError); + override installWorker( + requestId: number, + packageNames: readonly string[], + cwd: string, + onRequestCompleted: ts.server.typingsInstaller.RequestCompletedAction, + ): void { + this.installWorkerAsync( + requestId, + packageNames, + cwd, + onRequestCompleted, + ).catch(error => { + const errorMessage = error instanceof Error + ? error.message + : "Unknown installation error"; + + if (this.log.isEnabled()) { + (this.log as StructuredFileLog).logStructured( + "ERROR", + "install_worker_error", + { + requestId, + packages: packageNames, + error: errorMessage, + }, + ); + } + + onRequestCompleted(/*success*/ false); + }); } - /** Returns 'true' in case of error. */ - private execSyncAndLog(command: string, options: Pick): boolean { + private async installWorkerAsync( + requestId: number, + packageNames: readonly string[], + cwd: string, + onRequestCompleted: ts.server.typingsInstaller.RequestCompletedAction, + ): Promise { + const startTime = Date.now(); + this.metrics.installationsAttempted++; + if (this.log.isEnabled()) { - this.log.writeLine(`Exec: ${command}`); + (this.log as StructuredFileLog).logStructured( + "INFO", + "install_start", + { + requestId, + packages: packageNames, + cwd, + }, + ); } + try { - const stdout = execSync(command, { ...options, encoding: "utf-8" }); + // Filter packages that are already successfully installed + const packagesToInstall: string[] = []; + for (const pkg of packageNames) { + if (this.installationCache.isRecentlyInstalled(pkg)) { + this.metrics.cacheHits++; + } + else { + packagesToInstall.push(pkg); + } + } + + if (packagesToInstall.length === 0) { + if (this.log.isEnabled()) { + (this.log as StructuredFileLog).logStructured( + "INFO", + "install_cache_hit", + { + requestId, + packages: packageNames, + }, + ); + } + onRequestCompleted(/*success*/ true); + return; + } + + // Perform installation with retry logic + const success = await this.retryOperation( + () => this.npmClient.install(packagesToInstall, cwd), + this.config.maxRetries, + ); + + // Update cache for all packages + for (const pkg of packagesToInstall) { + this.installationCache.recordInstallation(pkg, success); + } + + const duration = Date.now() - startTime; + this.metrics.totalInstallTime += duration; + + if (success) { + this.metrics.installationsSucceeded++; + } + if (this.log.isEnabled()) { - this.log.writeLine(` Succeeded. stdout:${indent(sys.newLine, stdout)}`); + (this.log as StructuredFileLog).logStructured( + "INFO", + "install_complete", + { + requestId, + packages: packagesToInstall, + success, + duration, + cacheStats: this.installationCache.getCacheStats(), + }, + ); } - return false; + + onRequestCompleted(/*success*/ success); } catch (error) { - const { stdout, stderr } = error; - this.log.writeLine(` Failed. stdout:${indent(sys.newLine, stdout)}${sys.newLine} stderr:${indent(sys.newLine, stderr)}`); - return true; + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + // Record failure in cache + for (const pkg of packageNames) { + this.installationCache.recordInstallation( + pkg, + /*success*/ false, + ); + } + + if (this.log.isEnabled()) { + (this.log as StructuredFileLog).logStructured( + "ERROR", + "install_failed", + { + requestId, + packages: packageNames, + error: errorMessage, + duration, + }, + ); + } + + onRequestCompleted(/*success*/ false); + } + } + + // Cleanup method for proper resource management + cleanup(): void { + this.installationCache.clear(); + + if (this.log.isEnabled()) { + (this.log as StructuredFileLog).logStructured( + "INFO", + "installer_cleanup", + { + finalMetrics: this.metrics, + }, + ); } } } +// Process setup and message handling +function createInstaller( + globalCache: string, + log: StructuredFileLog, + safeListLoc?: string, + typesMapLoc?: string, + npmLocation?: string, + validateNpm = true, +): NodeTypingsInstaller { + return new NodeTypingsInstaller( + globalCache, + log, + safeListLoc, + typesMapLoc, + npmLocation, + validateNpm, + { + throttleLimit: 5, + registryPackageName: "types-registry", + cacheTimeoutMs: 24 * 60 * 60 * 1000, // 24 hours + maxRetries: 3, + npmTimeoutMs: 5 * 60 * 1000, // 5 minutes + maxCacheSize: 1000, + }, + ); +} + const logFilePath = ts.server.findArgument(ts.server.Arguments.LogFile); -const globalTypingsCacheLocation = ts.server.findArgument(ts.server.Arguments.GlobalCacheLocation); -const typingSafeListLocation = ts.server.findArgument(ts.server.Arguments.TypingSafeListLocation); -const typesMapLocation = ts.server.findArgument(ts.server.Arguments.TypesMapLocation); +const globalCache = ts.server.findArgument( + ts.server.Arguments.GlobalCacheLocation, +); +const safeListLoc = ts.server.findArgument( + ts.server.Arguments.TypingSafeListLocation, +); +const typesMapLoc = ts.server.findArgument( + ts.server.Arguments.TypesMapLocation, +); const npmLocation = ts.server.findArgument(ts.server.Arguments.NpmLocation); -const validateDefaultNpmLocation = ts.server.hasArgument(ts.server.Arguments.ValidateDefaultNpmLocation); +const validateNpm = ts.server.hasArgument( + ts.server.Arguments.ValidateDefaultNpmLocation, +); + +const log = new StructuredFileLog(logFilePath); + +let installer: NodeTypingsInstaller | undefined; + +function shutdown(exitCode: number, reason: string, logData?: any): void { + if (installer) { + installer.cleanup(); + } + + if (log.isEnabled()) { + log.logStructured(exitCode === 0 ? "INFO" : "FATAL", "shutdown", { + reason, + exitCode, + ...logData, + }); + } + + process.exit(exitCode); +} -const log = new FileLog(logFilePath); +// Handle uncaught exceptions if (log.isEnabled()) { - process.on("uncaughtException", (e: Error) => { - log.writeLine(`Unhandled exception: ${e} at ${e.stack}`); + process.on("uncaughtException", (error: Error) => { + shutdown(1, "uncaught_exception", { + error: error.message, + stack: error.stack, + }); + }); + + process.on("unhandledRejection", (reason: unknown) => { + const errorMessage = reason instanceof Error ? reason.message : String(reason); + const errorStack = reason instanceof Error ? reason.stack : undefined; + + shutdown(1, "unhandled_rejection", { + error: errorMessage, + stack: errorStack, + }); }); } + +// Handle parent process disconnect process.on("disconnect", () => { - if (log.isEnabled()) { - log.writeLine(`Parent process has exited, shutting down...`); - } - process.exit(0); + shutdown(0, "parent_disconnect", { + message: "Parent process disconnected, shutting down", + }); }); -let installer: NodeTypingsInstaller | undefined; + +// Handle process termination signals +process.on("SIGTERM", () => { + shutdown(0, "sigterm_received", { + message: "SIGTERM received, shutting down gracefully", + }); +}); + +process.on("SIGINT", () => { + shutdown(0, "sigint_received", { + message: "SIGINT received, shutting down gracefully", + }); +}); + process.on("message", (req: ts.server.TypingInstallerRequestUnion) => { - installer ??= new NodeTypingsInstaller(globalTypingsCacheLocation!, typingSafeListLocation!, typesMapLocation!, npmLocation, validateDefaultNpmLocation, /*throttleLimit*/ 5, log); // TODO: GH#18217 - installer.handleRequest(req); + try { + if (!installer) { + if (!globalCache) { + throw new Error("Global cache location is required"); + } + + installer = createInstaller( + globalCache, + log, + safeListLoc, + typesMapLoc, + npmLocation, + validateNpm, + ); + } + + installer.handleRequest(req); + } + catch (error) { + const errorMessage = error instanceof Error + ? error.message + : "Unknown message handling error"; + + if (log.isEnabled()) { + log.logStructured("ERROR", "message_handler_error", { + error: errorMessage, + request: req, + }); + } + + // Send error response + if (process.send) { + process.send({ + kind: "event::initializationFailed", + message: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + }); + } + } }); -function indent(newline: string, str: string | undefined): string { - return str && str.length - ? `${newline} ` + str.replace(/\r?\n/, `${newline} `) - : ""; -} +// Graceful shutdown handler +process.on("exit", () => { + if (installer) { + installer.cleanup(); + } +}); diff --git a/src/typingsInstaller/tsconfig.json b/src/typingsInstaller/tsconfig.json index 0bb3c6c017c7a..647db61c5054e 100644 --- a/src/typingsInstaller/tsconfig.json +++ b/src/typingsInstaller/tsconfig.json @@ -1,12 +1,8 @@ -{ - "extends": "../tsconfig-base", - "compilerOptions": { - "types": [ - "node" - ] - }, - "references": [ - { "path": "../typescript" } - ], - "include": ["**/*"] -} +{ + "extends": "../tsconfig-base", + "compilerOptions": { + "types": ["node"] + }, + "references": [{ "path": "../typescript" }], + "include": ["**/*"] +}