diff --git a/Gulpfile.js b/Gulpfile.js index ed77c96792004..729b49b8f4ff6 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -308,6 +308,8 @@ const watchLssl = () => watch([ "src/services/**/*.ts", "src/server/tsconfig.json", "src/server/**/*.ts", + "src/webServer/tsconfig.json", + "src/webServer/**/*.ts", "src/tsserver/tsconfig.json", "src/tsserver/**/*.ts", ], buildLssl); diff --git a/src/server/session.ts b/src/server/session.ts index a842694bb9a6b..c23656dc3b1b3 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -689,7 +689,7 @@ namespace ts.server { typesMapLocation?: string; } - export class Session implements EventSender { + export class Session implements EventSender { private readonly gcTimer: GcTimer; protected projectService: ProjectService; private changeSeq = 0; @@ -2907,7 +2907,7 @@ namespace ts.server { } } - public onMessage(message: string) { + public onMessage(message: TMessage) { this.gcTimer.scheduleCollect(); this.performanceData = undefined; @@ -2916,18 +2916,18 @@ namespace ts.server { if (this.logger.hasLevel(LogLevel.requestTime)) { start = this.hrtime(); if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`request:${indent(message)}`); + this.logger.info(`request:${indent(this.toStringMessage(message))}`); } } let request: protocol.Request | undefined; let relevantFile: protocol.FileRequestArgs | undefined; try { - request = JSON.parse(message); + request = this.parseMessage(message); relevantFile = request.arguments && (request as protocol.FileRequest).arguments.file ? (request as protocol.FileRequest).arguments : undefined; tracing.instant(tracing.Phase.Session, "request", { seq: request.seq, command: request.command }); - perfLogger.logStartCommand("" + request.command, message.substring(0, 100)); + perfLogger.logStartCommand("" + request.command, this.toStringMessage(message).substring(0, 100)); tracing.push(tracing.Phase.Session, "executeCommand", { seq: request.seq, command: request.command }, /*separateBeginAndEnd*/ true); const { response, responseRequired } = this.executeCommand(request); @@ -2965,7 +2965,7 @@ namespace ts.server { return; } - this.logErrorWorker(err, message, relevantFile); + this.logErrorWorker(err, this.toStringMessage(message), relevantFile); perfLogger.logStopCommand("" + (request && request.command), "Error: " + err); tracing.instant(tracing.Phase.Session, "commandError", { seq: request?.seq, command: request?.command, message: (err).message }); @@ -2978,6 +2978,14 @@ namespace ts.server { } } + protected parseMessage(message: TMessage): protocol.Request { + return JSON.parse(message as any as string); + } + + protected toStringMessage(message: TMessage): string { + return message as any as string; + } + private getFormatOptions(file: NormalizedPath): FormatCodeSettings { return this.projectService.getFormatCodeOptions(file); } diff --git a/src/testRunner/tsconfig.json b/src/testRunner/tsconfig.json index c024bdb0d6d37..9070a68b41f06 100644 --- a/src/testRunner/tsconfig.json +++ b/src/testRunner/tsconfig.json @@ -21,6 +21,7 @@ { "path": "../services", "prepend": true }, { "path": "../jsTyping", "prepend": true }, { "path": "../server", "prepend": true }, + { "path": "../webServer", "prepend": true }, { "path": "../typingsInstallerCore", "prepend": true }, { "path": "../harness", "prepend": true } ], @@ -204,6 +205,7 @@ "unittests/tsserver/typingsInstaller.ts", "unittests/tsserver/versionCache.ts", "unittests/tsserver/watchEnvironment.ts", + "unittests/tsserver/webServer.ts", "unittests/debugDeprecation.ts" ] } diff --git a/src/testRunner/unittests/tsserver/webServer.ts b/src/testRunner/unittests/tsserver/webServer.ts new file mode 100644 index 0000000000000..5ea6f48a8a92b --- /dev/null +++ b/src/testRunner/unittests/tsserver/webServer.ts @@ -0,0 +1,157 @@ +namespace ts.projectSystem { + describe("unittests:: tsserver:: webServer", () => { + class TestWorkerSession extends server.WorkerSession { + constructor(host: server.ServerHost, webHost: server.HostWithWriteMessage, options: Partial, logger: server.Logger) { + super( + host, + webHost, + { + globalPlugins: undefined, + pluginProbeLocations: undefined, + allowLocalPluginLoads: undefined, + useSingleInferredProject: true, + useInferredProjectPerProjectRoot: false, + suppressDiagnosticEvents: false, + noGetErrOnBackgroundUpdate: true, + syntaxOnly: undefined, + serverMode: undefined, + ...options + }, + logger, + server.nullCancellationToken, + () => emptyArray + ); + } + + getProjectService() { + return this.projectService; + } + } + function setup(logLevel: server.LogLevel | undefined) { + const host = createServerHost([libFile], { windowsStyleRoot: "c:/" }); + const messages: any[] = []; + const webHost: server.WebHost = { + readFile: s => host.readFile(s), + fileExists: s => host.fileExists(s), + writeMessage: s => messages.push(s), + }; + const webSys = server.createWebSystem(webHost, emptyArray, () => host.getExecutingFilePath()); + const logger = logLevel !== undefined ? new server.MainProcessLogger(logLevel, webHost) : nullLogger; + const session = new TestWorkerSession(webSys, webHost, { serverMode: LanguageServiceMode.PartialSemantic }, logger); + return { getMessages: () => messages, clearMessages: () => messages.length = 0, session }; + + } + + describe("open files are added to inferred project and semantic operations succeed", () => { + function verify(logLevel: server.LogLevel | undefined) { + const { session, clearMessages, getMessages } = setup(logLevel); + const service = session.getProjectService(); + const file: File = { + path: "^memfs:/sample-folder/large.ts", + content: "export const numberConst = 10; export const arrayConst: Array = [];" + }; + session.executeCommand({ + seq: 1, + type: "request", + command: protocol.CommandTypes.Open, + arguments: { + file: file.path, + fileContent: file.content + } + }); + checkNumberOfProjects(service, { inferredProjects: 1 }); + const project = service.inferredProjects[0]; + checkProjectActualFiles(project, ["/lib.d.ts", file.path]); // Lib files are rooted + verifyQuickInfo(); + verifyGotoDefInLib(); + + function verifyQuickInfo() { + clearMessages(); + const start = protocolFileLocationFromSubstring(file, "numberConst"); + session.onMessage({ + seq: 2, + type: "request", + command: protocol.CommandTypes.Quickinfo, + arguments: start + }); + assert.deepEqual(last(getMessages()), { + seq: 0, + type: "response", + command: protocol.CommandTypes.Quickinfo, + request_seq: 2, + success: true, + performanceData: undefined, + body: { + kind: ScriptElementKind.constElement, + kindModifiers: "export", + start: { line: start.line, offset: start.offset }, + end: { line: start.line, offset: start.offset + "numberConst".length }, + displayString: "const numberConst: 10", + documentation: "", + tags: [] + } + }); + verifyLogger(); + } + + function verifyGotoDefInLib() { + clearMessages(); + const start = protocolFileLocationFromSubstring(file, "Array"); + session.onMessage({ + seq: 3, + type: "request", + command: protocol.CommandTypes.DefinitionAndBoundSpan, + arguments: start + }); + assert.deepEqual(last(getMessages()), { + seq: 0, + type: "response", + command: protocol.CommandTypes.DefinitionAndBoundSpan, + request_seq: 3, + success: true, + performanceData: undefined, + body: { + definitions: [{ + file: "/lib.d.ts", + ...protocolTextSpanWithContextFromSubstring({ + fileText: libFile.content, + text: "Array", + contextText: "interface Array { length: number; [n: number]: T; }" + }) + }], + textSpan: { + start: { line: start.line, offset: start.offset }, + end: { line: start.line, offset: start.offset + "Array".length }, + } + } + }); + verifyLogger(); + } + + function verifyLogger() { + const messages = getMessages(); + assert.equal(messages.length, logLevel === server.LogLevel.verbose ? 4 : 1, `Expected ${JSON.stringify(messages)}`); + if (logLevel === server.LogLevel.verbose) { + verifyLogMessages(messages[0], "info"); + verifyLogMessages(messages[1], "perf"); + verifyLogMessages(messages[2], "info"); + } + clearMessages(); + } + + function verifyLogMessages(actual: any, expectedLevel: server.MessageLogLevel) { + assert.equal(actual.type, "log"); + assert.equal(actual.level, expectedLevel); + } + } + + it("with logging enabled", () => { + verify(server.LogLevel.verbose); + }); + + it("with logging disabled", () => { + verify(/*logLevel*/ undefined); + }); + }); + }); +} diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts new file mode 100644 index 0000000000000..f4b7061881bf3 --- /dev/null +++ b/src/tsserver/nodeServer.ts @@ -0,0 +1,918 @@ +/*@internal*/ +namespace ts.server { + interface LogOptions { + file?: string; + detailLevel?: LogLevel; + traceToConsole?: boolean; + logToFile?: boolean; + } + + interface NodeChildProcess { + send(message: any, sendHandle?: any): void; + on(message: "message" | "exit", f: (m: any) => void): void; + kill(): void; + pid: number; + } + + interface ReadLineOptions { + input: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; + terminal?: boolean; + historySize?: number; + } + + interface NodeSocket { + write(data: string, encoding: string): boolean; + } + + function parseLoggingEnvironmentString(logEnvStr: string | undefined): LogOptions { + if (!logEnvStr) { + return {}; + } + const logEnv: LogOptions = { logToFile: true }; + const args = logEnvStr.split(" "); + const len = args.length - 1; + for (let i = 0; i < len; i += 2) { + const option = args[i]; + const { value, extraPartCounter } = getEntireValue(i + 1); + i += extraPartCounter; + if (option && value) { + switch (option) { + case "-file": + logEnv.file = value; + break; + case "-level": + const level = getLogLevel(value); + logEnv.detailLevel = level !== undefined ? level : LogLevel.normal; + break; + case "-traceToConsole": + logEnv.traceToConsole = value.toLowerCase() === "true"; + break; + case "-logToFile": + logEnv.logToFile = value.toLowerCase() === "true"; + break; + } + } + } + return logEnv; + + function getEntireValue(initialIndex: number) { + let pathStart = args[initialIndex]; + let extraPartCounter = 0; + if (pathStart.charCodeAt(0) === CharacterCodes.doubleQuote && + pathStart.charCodeAt(pathStart.length - 1) !== CharacterCodes.doubleQuote) { + for (let i = initialIndex + 1; i < args.length; i++) { + pathStart += " "; + pathStart += args[i]; + extraPartCounter++; + if (pathStart.charCodeAt(pathStart.length - 1) === CharacterCodes.doubleQuote) break; + } + } + return { value: stripQuotes(pathStart), extraPartCounter }; + } + } + + function parseServerMode(): LanguageServiceMode | string | undefined { + const mode = findArgument("--serverMode"); + if (!mode) return undefined; + + switch (mode.toLowerCase()) { + case "semantic": + return LanguageServiceMode.Semantic; + case "partialsemantic": + return LanguageServiceMode.PartialSemantic; + case "syntactic": + return LanguageServiceMode.Syntactic; + default: + return mode; + } + } + + export function initializeNodeSystem(): StartInput { + const sys = Debug.checkDefined(ts.sys); + const childProcess: { + execFileSync(file: string, args: string[], options: { stdio: "ignore", env: MapLike }): string | Buffer; + } = require("child_process"); + + interface Stats { + isFile(): boolean; + isDirectory(): boolean; + isBlockDevice(): boolean; + isCharacterDevice(): boolean; + isSymbolicLink(): boolean; + isFIFO(): boolean; + isSocket(): boolean; + dev: number; + ino: number; + mode: number; + nlink: number; + uid: number; + gid: number; + rdev: number; + size: number; + blksize: number; + blocks: number; + atime: Date; + mtime: Date; + ctime: Date; + birthtime: Date; + } + + const fs: { + openSync(path: string, options: string): number; + close(fd: number, callback: (err: NodeJS.ErrnoException) => void): void; + writeSync(fd: number, buffer: Buffer, offset: number, length: number, position?: number): number; + statSync(path: string): Stats; + stat(path: string, callback?: (err: NodeJS.ErrnoException, stats: Stats) => any): void; + } = require("fs"); + + class Logger extends BaseLogger { + private fd = -1; + constructor( + private readonly logFilename: string, + private readonly traceToConsole: boolean, + level: LogLevel + ) { + super(level); + if (this.logFilename) { + try { + this.fd = fs.openSync(this.logFilename, "w"); + } + catch (_) { + // swallow the error and keep logging disabled if file cannot be opened + } + } + } + + close() { + if (this.fd >= 0) { + fs.close(this.fd, noop); + } + } + + getLogFileName() { + return this.logFilename; + } + + loggingEnabled() { + return !!this.logFilename || this.traceToConsole; + } + + protected canWrite() { + return this.fd >= 0 || this.traceToConsole; + } + + protected write(s: string, _type: Msg) { + if (this.fd >= 0) { + const buf = sys.bufferFrom!(s); + // eslint-disable-next-line no-null/no-null + fs.writeSync(this.fd, buf as globalThis.Buffer, 0, buf.length, /*position*/ null!); // TODO: GH#18217 + } + if (this.traceToConsole) { + console.warn(s); + } + } + } + + const nodeVersion = getNodeMajorVersion(); + // use watchGuard process on Windows when node version is 4 or later + const useWatchGuard = process.platform === "win32" && nodeVersion! >= 4; + const originalWatchDirectory: ServerHost["watchDirectory"] = sys.watchDirectory.bind(sys); + const logger = createLogger(); + + // REVIEW: for now this implementation uses polling. + // The advantage of polling is that it works reliably + // on all os and with network mounted files. + // For 90 referenced files, the average time to detect + // changes is 2*msInterval (by default 5 seconds). + // The overhead of this is .04 percent (1/2500) with + // average pause of < 1 millisecond (and max + // pause less than 1.5 milliseconds); question is + // do we anticipate reference sets in the 100s and + // do we care about waiting 10-20 seconds to detect + // changes for large reference sets? If so, do we want + // to increase the chunk size or decrease the interval + // time dynamically to match the large reference set? + const pollingWatchedFileSet = createPollingWatchedFileSet(); + + const pending: Buffer[] = []; + let canWrite = true; + + if (useWatchGuard) { + const currentDrive = extractWatchDirectoryCacheKey(sys.resolvePath(sys.getCurrentDirectory()), /*currentDriveKey*/ undefined); + const statusCache = new Map(); + sys.watchDirectory = (path, callback, recursive, options) => { + const cacheKey = extractWatchDirectoryCacheKey(path, currentDrive); + let status = cacheKey && statusCache.get(cacheKey); + if (status === undefined) { + if (logger.hasLevel(LogLevel.verbose)) { + logger.info(`${cacheKey} for path ${path} not found in cache...`); + } + try { + const args = [combinePaths(__dirname, "watchGuard.js"), path]; + if (logger.hasLevel(LogLevel.verbose)) { + logger.info(`Starting ${process.execPath} with args:${stringifyIndented(args)}`); + } + childProcess.execFileSync(process.execPath, args, { stdio: "ignore", env: { ELECTRON_RUN_AS_NODE: "1" } }); + status = true; + if (logger.hasLevel(LogLevel.verbose)) { + logger.info(`WatchGuard for path ${path} returned: OK`); + } + } + catch (e) { + status = false; + if (logger.hasLevel(LogLevel.verbose)) { + logger.info(`WatchGuard for path ${path} returned: ${e.message}`); + } + } + if (cacheKey) { + statusCache.set(cacheKey, status); + } + } + else if (logger.hasLevel(LogLevel.verbose)) { + logger.info(`watchDirectory for ${path} uses cached drive information.`); + } + if (status) { + // this drive is safe to use - call real 'watchDirectory' + return watchDirectorySwallowingException(path, callback, recursive, options); + } + else { + // this drive is unsafe - return no-op watcher + return noopFileWatcher; + } + }; + } + else { + sys.watchDirectory = watchDirectorySwallowingException; + } + + // Override sys.write because fs.writeSync is not reliable on Node 4 + sys.write = (s: string) => writeMessage(sys.bufferFrom!(s, "utf8") as globalThis.Buffer); + sys.watchFile = (fileName, callback) => { + const watchedFile = pollingWatchedFileSet.addFile(fileName, callback); + return { + close: () => pollingWatchedFileSet.removeFile(watchedFile) + }; + }; + + /* eslint-disable no-restricted-globals */ + sys.setTimeout = setTimeout; + sys.clearTimeout = clearTimeout; + sys.setImmediate = setImmediate; + sys.clearImmediate = clearImmediate; + /* eslint-enable no-restricted-globals */ + + if (typeof global !== "undefined" && global.gc) { + sys.gc = () => global.gc(); + } + + sys.require = (initialDir: string, moduleName: string): RequireResult => { + try { + return { module: require(resolveJSModule(moduleName, initialDir, sys)), error: undefined }; + } + catch (error) { + return { module: undefined, error }; + } + }; + + let cancellationToken: ServerCancellationToken; + try { + const factory = require("./cancellationToken"); + cancellationToken = factory(sys.args); + } + catch (e) { + cancellationToken = nullCancellationToken; + } + + const localeStr = findArgument("--locale"); + if (localeStr) { + validateLocaleAndSetLanguage(localeStr, sys); + } + + const modeOrUnknown = parseServerMode(); + let serverMode: LanguageServiceMode | undefined; + let unknownServerMode: string | undefined; + if (modeOrUnknown !== undefined) { + if (typeof modeOrUnknown === "number") serverMode = modeOrUnknown; + else unknownServerMode = modeOrUnknown; + } + return { + args: process.argv, + logger, + cancellationToken, + serverMode, + unknownServerMode, + startSession: startNodeSession + }; + + // TSS_LOG "{ level: "normal | verbose | terse", file?: string}" + function createLogger() { + const cmdLineLogFileName = findArgument("--logFile"); + const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); + const envLogOptions = parseLoggingEnvironmentString(process.env.TSS_LOG); + + const unsubstitutedLogFileName = cmdLineLogFileName + ? stripQuotes(cmdLineLogFileName) + : envLogOptions.logToFile + ? envLogOptions.file || (__dirname + "/.log" + process.pid.toString()) + : undefined; + + const substitutedLogFileName = unsubstitutedLogFileName + ? unsubstitutedLogFileName.replace("PID", process.pid.toString()) + : undefined; + + const logVerbosity = cmdLineVerbosity || envLogOptions.detailLevel; + return new Logger(substitutedLogFileName!, envLogOptions.traceToConsole!, logVerbosity!); // TODO: GH#18217 + } + // This places log file in the directory containing editorServices.js + // TODO: check that this location is writable + + // average async stat takes about 30 microseconds + // set chunk size to do 30 files in < 1 millisecond + function createPollingWatchedFileSet(interval = 2500, chunkSize = 30) { + const watchedFiles: WatchedFile[] = []; + let nextFileToCheck = 0; + return { getModifiedTime, poll, startWatchTimer, addFile, removeFile }; + + function getModifiedTime(fileName: string): Date { + // Caller guarantees that `fileName` exists, so there'd be no benefit from throwIfNoEntry + return fs.statSync(fileName).mtime; + } + + function poll(checkedIndex: number) { + const watchedFile = watchedFiles[checkedIndex]; + if (!watchedFile) { + return; + } + + fs.stat(watchedFile.fileName, (err, stats) => { + if (err) { + if (err.code === "ENOENT") { + if (watchedFile.mtime.getTime() !== 0) { + watchedFile.mtime = missingFileModifiedTime; + watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Deleted); + } + } + else { + watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Changed); + } + } + else { + onWatchedFileStat(watchedFile, stats.mtime); + } + }); + } + + // this implementation uses polling and + // stat due to inconsistencies of fs.watch + // and efficiency of stat on modern filesystems + function startWatchTimer() { + // eslint-disable-next-line no-restricted-globals + setInterval(() => { + let count = 0; + let nextToCheck = nextFileToCheck; + let firstCheck = -1; + while ((count < chunkSize) && (nextToCheck !== firstCheck)) { + poll(nextToCheck); + if (firstCheck < 0) { + firstCheck = nextToCheck; + } + nextToCheck++; + if (nextToCheck === watchedFiles.length) { + nextToCheck = 0; + } + count++; + } + nextFileToCheck = nextToCheck; + }, interval); + } + + function addFile(fileName: string, callback: FileWatcherCallback): WatchedFile { + const file: WatchedFile = { + fileName, + callback, + mtime: sys.fileExists(fileName) + ? getModifiedTime(fileName) + : missingFileModifiedTime // Any subsequent modification will occur after this time + }; + + watchedFiles.push(file); + if (watchedFiles.length === 1) { + startWatchTimer(); + } + return file; + } + + function removeFile(file: WatchedFile) { + unorderedRemoveItem(watchedFiles, file); + } + } + + function writeMessage(buf: Buffer) { + if (!canWrite) { + pending.push(buf); + } + else { + canWrite = false; + process.stdout.write(buf, setCanWriteFlagAndWriteMessageIfNecessary); + } + } + + function setCanWriteFlagAndWriteMessageIfNecessary() { + canWrite = true; + if (pending.length) { + writeMessage(pending.shift()!); + } + } + + function extractWatchDirectoryCacheKey(path: string, currentDriveKey: string | undefined) { + path = normalizeSlashes(path); + if (isUNCPath(path)) { + // UNC path: extract server name + // //server/location + // ^ <- from 0 to this position + const firstSlash = path.indexOf(directorySeparator, 2); + return firstSlash !== -1 ? toFileNameLowerCase(path.substring(0, firstSlash)) : path; + } + const rootLength = getRootLength(path); + if (rootLength === 0) { + // relative path - assume file is on the current drive + return currentDriveKey; + } + if (path.charCodeAt(1) === CharacterCodes.colon && path.charCodeAt(2) === CharacterCodes.slash) { + // rooted path that starts with c:/... - extract drive letter + return toFileNameLowerCase(path.charAt(0)); + } + if (path.charCodeAt(0) === CharacterCodes.slash && path.charCodeAt(1) !== CharacterCodes.slash) { + // rooted path that starts with slash - /somename - use key for current drive + return currentDriveKey; + } + // do not cache any other cases + return undefined; + } + + function isUNCPath(s: string): boolean { + return s.length > 2 && s.charCodeAt(0) === CharacterCodes.slash && s.charCodeAt(1) === CharacterCodes.slash; + } + + // This is the function that catches the exceptions when watching directory, and yet lets project service continue to function + // Eg. on linux the number of watches are limited and one could easily exhaust watches and the exception ENOSPC is thrown when creating watcher at that point + function watchDirectorySwallowingException(path: string, callback: DirectoryWatcherCallback, recursive?: boolean, options?: WatchOptions): FileWatcher { + try { + return originalWatchDirectory(path, callback, recursive, options); + } + catch (e) { + logger.info(`Exception when creating directory watcher: ${e.message}`); + return noopFileWatcher; + } + } + } + + function parseEventPort(eventPortStr: string | undefined) { + const eventPort = eventPortStr === undefined ? undefined : parseInt(eventPortStr); + return eventPort !== undefined && !isNaN(eventPort) ? eventPort : undefined; + } + + function startNodeSession(options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) { + const childProcess: { + fork(modulePath: string, args: string[], options?: { execArgv: string[], env?: MapLike }): NodeChildProcess; + } = require("child_process"); + + const os: { + homedir?(): string; + tmpdir(): string; + } = require("os"); + + const net: { + connect(options: { port: number }, onConnect?: () => void): NodeSocket + } = require("net"); + + const readline: { + createInterface(options: ReadLineOptions): NodeJS.EventEmitter; + } = require("readline"); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, + }); + + interface QueuedOperation { + operationId: string; + operation: () => void; + } + + class NodeTypingsInstaller implements ITypingsInstaller { + private installer!: NodeChildProcess; + private projectService!: ProjectService; + private activeRequestCount = 0; + private requestQueue: QueuedOperation[] = []; + private requestMap = new Map(); // 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 = false; + private typesRegistryCache: ESMap> | 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 + // (see https://github.com/nodejs/node/issues/7657). + // It would be preferable to base our limit on the amount of space left in the + // buffer, but we have yet to find a way to retrieve that value. + private static readonly maxActiveRequestCount = 10; + private static readonly requestDelayMillis = 100; + private packageInstalledPromise: { resolve(value: ApplyCodeActionCommandResult): void, reject(reason: unknown): void } | undefined; + + constructor( + private readonly telemetryEnabled: boolean, + private readonly logger: Logger, + private readonly host: ServerHost, + readonly globalTypingsCacheLocation: string, + readonly typingSafeListLocation: string, + readonly typesMapLocation: string, + private readonly npmLocation: string | undefined, + private readonly validateDefaultNpmLocation: boolean, + private event: Event) { + } + + isKnownTypesPackageName(name: string): boolean { + // We want to avoid looking this up in the registry as that is expensive. So first check that it's actually an NPM package. + const validationResult = JsTyping.validatePackageName(name); + if (validationResult !== JsTyping.NameValidationResult.Ok) { + return false; + } + + if (this.requestedRegistry) { + return !!this.typesRegistryCache && this.typesRegistryCache.has(name); + } + + this.requestedRegistry = true; + this.send({ kind: "typesRegistry" }); + return false; + } + + installPackage(options: InstallPackageOptionsWithProject): Promise { + this.send({ kind: "installPackage", ...options }); + Debug.assert(this.packageInstalledPromise === undefined); + return new Promise((resolve, reject) => { + this.packageInstalledPromise = { resolve, reject }; + }); + } + + attach(projectService: ProjectService) { + this.projectService = projectService; + if (this.logger.hasLevel(LogLevel.requestTime)) { + this.logger.info("Binding..."); + } + + const args: string[] = [Arguments.GlobalCacheLocation, this.globalTypingsCacheLocation]; + if (this.telemetryEnabled) { + args.push(Arguments.EnableTelemetry); + } + if (this.logger.loggingEnabled() && this.logger.getLogFileName()) { + args.push(Arguments.LogFile, combinePaths(getDirectoryPath(normalizeSlashes(this.logger.getLogFileName()!)), `ti-${process.pid}.log`)); + } + if (this.typingSafeListLocation) { + args.push(Arguments.TypingSafeListLocation, this.typingSafeListLocation); + } + if (this.typesMapLocation) { + args.push(Arguments.TypesMapLocation, this.typesMapLocation); + } + if (this.npmLocation) { + args.push(Arguments.NpmLocation, this.npmLocation); + } + if (this.validateDefaultNpmLocation) { + args.push(Arguments.ValidateDefaultNpmLocation); + } + + const execArgv: string[] = []; + for (const arg of process.execArgv) { + const match = /^--((?:debug|inspect)(?:-brk)?)(?:=(\d+))?$/.exec(arg); + if (match) { + // if port is specified - use port + 1 + // otherwise pick a default port depending on if 'debug' or 'inspect' and use its value + 1 + const currentPort = match[2] !== undefined + ? +match[2] + : match[1].charAt(0) === "d" ? 5858 : 9229; + execArgv.push(`--${match[1]}=${currentPort + 1}`); + break; + } + } + + this.installer = childProcess.fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv }); + this.installer.on("message", m => this.handleMessage(m)); + + // We have to schedule this event to the next tick + // cause this fn will be called during + // new IOSession => super(which is Session) => new ProjectService => NodeTypingsInstaller.attach + // and if "event" is referencing "this" before super class is initialized, it will be a ReferenceError in ES6 class. + this.host.setImmediate(() => this.event({ pid: this.installer.pid }, "typingsInstallerPid")); + + process.on("exit", () => { + this.installer.kill(); + }); + } + + onProjectClosed(p: Project): void { + this.send({ projectName: p.getProjectName(), kind: "closeProject" }); + } + + private send(rq: T): void { + this.installer.send(rq); + } + + enqueueInstallTypingsRequest(project: Project, typeAcquisition: TypeAcquisition, unresolvedImports: SortedReadonlyArray): void { + const request = createInstallTypingsRequest(project, typeAcquisition, unresolvedImports); + if (this.logger.hasLevel(LogLevel.verbose)) { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Scheduling throttled operation:${stringifyIndented(request)}`); + } + } + + const operationId = project.getProjectName(); + const operation = () => { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Sending request:${stringifyIndented(request)}`); + } + this.send(request); + }; + const queuedRequest: QueuedOperation = { operationId, operation }; + + if (this.activeRequestCount < NodeTypingsInstaller.maxActiveRequestCount) { + this.scheduleRequest(queuedRequest); + } + else { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Deferring request for: ${operationId}`); + } + this.requestQueue.push(queuedRequest); + this.requestMap.set(operationId, queuedRequest); + } + } + + private handleMessage(response: TypesRegistryResponse | PackageInstalledResponse | SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse) { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Received response:${stringifyIndented(response)}`); + } + + switch (response.kind) { + case EventTypesRegistry: + this.typesRegistryCache = new Map(getEntries(response.typesRegistry)); + break; + case ActionPackageInstalled: { + const { success, message } = response; + if (success) { + this.packageInstalledPromise!.resolve({ successMessage: message }); + } + else { + this.packageInstalledPromise!.reject(message); + } + this.packageInstalledPromise = undefined; + + this.projectService.updateTypingsForProject(response); + + // The behavior is the same as for setTypings, so send the same event. + this.event(response, "setTypings"); + break; + } + case EventInitializationFailed: { + const body: protocol.TypesInstallerInitializationFailedEventBody = { + message: response.message + }; + const eventName: protocol.TypesInstallerInitializationFailedEventName = "typesInstallerInitializationFailed"; + this.event(body, eventName); + break; + } + case EventBeginInstallTypes: { + const body: protocol.BeginInstallTypesEventBody = { + eventId: response.eventId, + packages: response.packagesToInstall, + }; + const eventName: protocol.BeginInstallTypesEventName = "beginInstallTypes"; + this.event(body, eventName); + break; + } + case EventEndInstallTypes: { + if (this.telemetryEnabled) { + const body: protocol.TypingsInstalledTelemetryEventBody = { + telemetryEventName: "typingsInstalled", + payload: { + installedPackages: response.packagesToInstall.join(","), + installSuccess: response.installSuccess, + typingsInstallerVersion: response.typingsInstallerVersion + } + }; + const eventName: protocol.TelemetryEventName = "telemetry"; + this.event(body, eventName); + } + + const body: protocol.EndInstallTypesEventBody = { + eventId: response.eventId, + packages: response.packagesToInstall, + success: response.installSuccess, + }; + const eventName: protocol.EndInstallTypesEventName = "endInstallTypes"; + this.event(body, eventName); + break; + } + case ActionInvalidate: { + this.projectService.updateTypingsForProject(response); + break; + } + case ActionSet: { + if (this.activeRequestCount > 0) { + this.activeRequestCount--; + } + else { + Debug.fail("Received too many responses"); + } + + while (this.requestQueue.length > 0) { + const queuedRequest = this.requestQueue.shift()!; + if (this.requestMap.get(queuedRequest.operationId) === queuedRequest) { + this.requestMap.delete(queuedRequest.operationId); + this.scheduleRequest(queuedRequest); + break; + } + + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Skipping defunct request for: ${queuedRequest.operationId}`); + } + } + + this.projectService.updateTypingsForProject(response); + + this.event(response, "setTypings"); + + break; + } + default: + assertType(response); + } + } + + private scheduleRequest(request: QueuedOperation) { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Scheduling request for: ${request.operationId}`); + } + this.activeRequestCount++; + this.host.setTimeout(request.operation, NodeTypingsInstaller.requestDelayMillis); + } + } + + class IOSession extends Session { + private eventPort: number | undefined; + private eventSocket: NodeSocket | undefined; + private socketEventQueue: { body: any, eventName: string }[] | undefined; + /** No longer needed if syntax target is es6 or above. Any access to "this" before initialized will be a runtime error. */ + private constructed: boolean | undefined; + + constructor() { + const event = (body: object, eventName: string) => { + this.event(body, eventName); + }; + + const host = sys as ServerHost; + + const typingsInstaller = disableAutomaticTypingAcquisition + ? undefined + : new NodeTypingsInstaller(telemetryEnabled, logger, host, getGlobalTypingsCacheLocation(), typingSafeListLocation, typesMapLocation, npmLocation, validateDefaultNpmLocation, event); + + super({ + host, + cancellationToken, + ...options, + typingsInstaller: typingsInstaller || nullTypingsInstaller, + byteLength: Buffer.byteLength, + hrtime: process.hrtime, + logger, + canUseEvents: true, + typesMapLocation, + }); + + this.eventPort = eventPort; + if (this.canUseEvents && this.eventPort) { + const s = net.connect({ port: this.eventPort }, () => { + this.eventSocket = s; + if (this.socketEventQueue) { + // flush queue. + for (const event of this.socketEventQueue) { + this.writeToEventSocket(event.body, event.eventName); + } + this.socketEventQueue = undefined; + } + }); + } + + this.constructed = true; + } + + event(body: T, eventName: string): void { + Debug.assert(!!this.constructed, "Should only call `IOSession.prototype.event` on an initialized IOSession"); + + if (this.canUseEvents && this.eventPort) { + if (!this.eventSocket) { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`eventPort: event "${eventName}" queued, but socket not yet initialized`); + } + (this.socketEventQueue || (this.socketEventQueue = [])).push({ body, eventName }); + return; + } + else { + Debug.assert(this.socketEventQueue === undefined); + this.writeToEventSocket(body, eventName); + } + } + else { + super.event(body, eventName); + } + } + + private writeToEventSocket(body: object, eventName: string): void { + this.eventSocket!.write(formatMessage(toEvent(eventName, body), this.logger, this.byteLength, this.host.newLine), "utf8"); + } + + exit() { + this.logger.info("Exiting..."); + this.projectService.closeLog(); + if (traceDir) { + tracing.stopTracing(ts.emptyArray); + } + process.exit(0); + } + + listen() { + rl.on("line", (input: string) => { + const message = input.trim(); + this.onMessage(message); + }); + + rl.on("close", () => { + this.exit(); + }); + } + } + + const eventPort: number | undefined = parseEventPort(findArgument("--eventPort")); + const typingSafeListLocation = findArgument(Arguments.TypingSafeListLocation)!; // TODO: GH#18217 + const typesMapLocation = findArgument(Arguments.TypesMapLocation) || combinePaths(getDirectoryPath(sys.getExecutingFilePath()), "typesMap.json"); + const npmLocation = findArgument(Arguments.NpmLocation); + const validateDefaultNpmLocation = hasArgument(Arguments.ValidateDefaultNpmLocation); + const disableAutomaticTypingAcquisition = hasArgument("--disableAutomaticTypingAcquisition"); + const telemetryEnabled = hasArgument(Arguments.EnableTelemetry); + const commandLineTraceDir = findArgument("--traceDirectory"); + const traceDir = commandLineTraceDir + ? stripQuotes(commandLineTraceDir) + : process.env.TSS_TRACE; + if (traceDir) { + tracing.startTracing(tracing.Mode.Server, traceDir); + } + + const ioSession = new IOSession(); + process.on("uncaughtException", err => { + ioSession.logError(err, "unknown"); + }); + // See https://github.com/Microsoft/TypeScript/issues/11348 + (process as any).noAsar = true; + // Start listening + ioSession.listen(); + + function getGlobalTypingsCacheLocation() { + switch (process.platform) { + case "win32": { + const basePath = process.env.LOCALAPPDATA || + process.env.APPDATA || + (os.homedir && os.homedir()) || + process.env.USERPROFILE || + (process.env.HOMEDRIVE && process.env.HOMEPATH && normalizeSlashes(process.env.HOMEDRIVE + process.env.HOMEPATH)) || + os.tmpdir(); + return combinePaths(combinePaths(normalizeSlashes(basePath), "Microsoft/TypeScript"), versionMajorMinor); + } + case "openbsd": + case "freebsd": + case "netbsd": + case "darwin": + case "linux": + case "android": { + const cacheLocation = getNonWindowsCacheLocation(process.platform === "darwin"); + return combinePaths(combinePaths(cacheLocation, "typescript"), versionMajorMinor); + } + default: + return Debug.fail(`unsupported platform '${process.platform}'`); + } + } + + function getNonWindowsCacheLocation(platformIsDarwin: boolean) { + if (process.env.XDG_CACHE_HOME) { + return process.env.XDG_CACHE_HOME; + } + const usersDir = platformIsDarwin ? "Users" : "home"; + const homePath = (os.homedir && os.homedir()) || + process.env.HOME || + ((process.env.LOGNAME || process.env.USER) && `/${usersDir}/${process.env.LOGNAME || process.env.USER}`) || + os.tmpdir(); + const cacheFolder = platformIsDarwin + ? "Library/Caches" + : ".cache"; + return combinePaths(normalizeSlashes(homePath), cacheFolder); + } + } +} diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index 9a2c165a4ff41..7bb13ca46a2e8 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -1,642 +1,16 @@ +/*@internal*/ namespace ts.server { - const childProcess: { - fork(modulePath: string, args: string[], options?: { execArgv: string[], env?: MapLike }): NodeChildProcess; - execFileSync(file: string, args: string[], options: { stdio: "ignore", env: MapLike }): string | Buffer; - } = require("child_process"); - - const os: { - homedir?(): string; - tmpdir(): string; - platform(): string; - } = require("os"); - - interface NodeSocket { - write(data: string, encoding: string): boolean; - } - - const net: { - connect(options: { port: number }, onConnect?: () => void): NodeSocket - } = require("net"); - - function getGlobalTypingsCacheLocation() { - switch (process.platform) { - case "win32": { - const basePath = process.env.LOCALAPPDATA || - process.env.APPDATA || - (os.homedir && os.homedir()) || - process.env.USERPROFILE || - (process.env.HOMEDRIVE && process.env.HOMEPATH && normalizeSlashes(process.env.HOMEDRIVE + process.env.HOMEPATH)) || - os.tmpdir(); - return combinePaths(combinePaths(normalizeSlashes(basePath), "Microsoft/TypeScript"), versionMajorMinor); - } - case "openbsd": - case "freebsd": - case "netbsd": - case "darwin": - case "linux": - case "android": { - const cacheLocation = getNonWindowsCacheLocation(process.platform === "darwin"); - return combinePaths(combinePaths(cacheLocation, "typescript"), versionMajorMinor); - } - default: - return Debug.fail(`unsupported platform '${process.platform}'`); - } - } - - function getNonWindowsCacheLocation(platformIsDarwin: boolean) { - if (process.env.XDG_CACHE_HOME) { - return process.env.XDG_CACHE_HOME; - } - const usersDir = platformIsDarwin ? "Users" : "home"; - const homePath = (os.homedir && os.homedir()) || - process.env.HOME || - ((process.env.LOGNAME || process.env.USER) && `/${usersDir}/${process.env.LOGNAME || process.env.USER}`) || - os.tmpdir(); - const cacheFolder = platformIsDarwin - ? "Library/Caches" - : ".cache"; - return combinePaths(normalizeSlashes(homePath), cacheFolder); - } - - interface NodeChildProcess { - send(message: any, sendHandle?: any): void; - on(message: "message" | "exit", f: (m: any) => void): void; - kill(): void; - pid: number; - } - - interface ReadLineOptions { - input: NodeJS.ReadableStream; - output?: NodeJS.WritableStream; - terminal?: boolean; - historySize?: number; - } - - interface Stats { - isFile(): boolean; - isDirectory(): boolean; - isBlockDevice(): boolean; - isCharacterDevice(): boolean; - isSymbolicLink(): boolean; - isFIFO(): boolean; - isSocket(): boolean; - dev: number; - ino: number; - mode: number; - nlink: number; - uid: number; - gid: number; - rdev: number; - size: number; - blksize: number; - blocks: number; - atime: Date; - mtime: Date; - ctime: Date; - birthtime: Date; - } - - const readline: { - createInterface(options: ReadLineOptions): NodeJS.EventEmitter; - } = require("readline"); - - const fs: { - openSync(path: string, options: string): number; - close(fd: number, callback: (err: NodeJS.ErrnoException) => void): void; - writeSync(fd: number, buffer: Buffer, offset: number, length: number, position?: number): number; - writeSync(fd: number, data: any, position?: number, enconding?: string): number; - statSync(path: string): Stats; - stat(path: string, callback?: (err: NodeJS.ErrnoException, stats: Stats) => any): void; - } = require("fs"); - - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false, - }); - - class Logger implements server.Logger { // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier - private fd = -1; - private seq = 0; - private inGroup = false; - private firstInGroup = true; - - constructor(private readonly logFilename: string, - private readonly traceToConsole: boolean, - private readonly level: LogLevel) { - if (this.logFilename) { - try { - this.fd = fs.openSync(this.logFilename, "w"); - } - catch (_) { - // swallow the error and keep logging disabled if file cannot be opened - } - } - } - - static padStringRight(str: string, padding: string) { - return (str + padding).slice(0, padding.length); - } - - close() { - if (this.fd >= 0) { - fs.close(this.fd, noop); - } - } - - getLogFileName() { - return this.logFilename; - } - - perftrc(s: string) { - this.msg(s, Msg.Perf); - } - - info(s: string) { - this.msg(s, Msg.Info); - } - - err(s: string) { - this.msg(s, Msg.Err); - } - - startGroup() { - this.inGroup = true; - this.firstInGroup = true; - } - - endGroup() { - this.inGroup = false; - } - - loggingEnabled() { - return !!this.logFilename || this.traceToConsole; - } - - hasLevel(level: LogLevel) { - return this.loggingEnabled() && this.level >= level; - } - - msg(s: string, type: Msg = Msg.Err) { - switch (type) { - case Msg.Info: - perfLogger.logInfoEvent(s); - break; - case Msg.Perf: - perfLogger.logPerfEvent(s); - break; - default: // Msg.Err - perfLogger.logErrEvent(s); - break; - } - - if (!this.canWrite) return; - - s = `[${nowString()}] ${s}\n`; - if (!this.inGroup || this.firstInGroup) { - const prefix = Logger.padStringRight(type + " " + this.seq.toString(), " "); - s = prefix + s; - } - this.write(s); - if (!this.inGroup) { - this.seq++; - } - } - - private get canWrite() { - return this.fd >= 0 || this.traceToConsole; - } - - private write(s: string) { - if (this.fd >= 0) { - const buf = sys.bufferFrom!(s); - // eslint-disable-next-line no-null/no-null - fs.writeSync(this.fd, buf as globalThis.Buffer, 0, buf.length, /*position*/ null!); // TODO: GH#18217 - } - if (this.traceToConsole) { - console.warn(s); - } - } - } - - interface QueuedOperation { - operationId: string; - operation: () => void; - } - - class NodeTypingsInstaller implements ITypingsInstaller { - private installer!: NodeChildProcess; - private projectService!: ProjectService; - private activeRequestCount = 0; - private requestQueue: QueuedOperation[] = []; - private requestMap = new Map(); // 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 = false; - private typesRegistryCache: ESMap> | 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 - // (see https://github.com/nodejs/node/issues/7657). - // It would be preferable to base our limit on the amount of space left in the - // buffer, but we have yet to find a way to retrieve that value. - private static readonly maxActiveRequestCount = 10; - private static readonly requestDelayMillis = 100; - private packageInstalledPromise: { resolve(value: ApplyCodeActionCommandResult): void, reject(reason: unknown): void } | undefined; - - constructor( - private readonly telemetryEnabled: boolean, - private readonly logger: Logger, - private readonly host: ServerHost, - readonly globalTypingsCacheLocation: string, - readonly typingSafeListLocation: string, - readonly typesMapLocation: string, - private readonly npmLocation: string | undefined, - private readonly validateDefaultNpmLocation: boolean, - private event: Event) { - } - - isKnownTypesPackageName(name: string): boolean { - // We want to avoid looking this up in the registry as that is expensive. So first check that it's actually an NPM package. - const validationResult = JsTyping.validatePackageName(name); - if (validationResult !== JsTyping.NameValidationResult.Ok) { - return false; - } - - if (this.requestedRegistry) { - return !!this.typesRegistryCache && this.typesRegistryCache.has(name); - } - - this.requestedRegistry = true; - this.send({ kind: "typesRegistry" }); - return false; - } - - installPackage(options: InstallPackageOptionsWithProject): Promise { - this.send({ kind: "installPackage", ...options }); - Debug.assert(this.packageInstalledPromise === undefined); - return new Promise((resolve, reject) => { - this.packageInstalledPromise = { resolve, reject }; - }); - } - - attach(projectService: ProjectService) { - this.projectService = projectService; - if (this.logger.hasLevel(LogLevel.requestTime)) { - this.logger.info("Binding..."); - } - - const args: string[] = [Arguments.GlobalCacheLocation, this.globalTypingsCacheLocation]; - if (this.telemetryEnabled) { - args.push(Arguments.EnableTelemetry); - } - if (this.logger.loggingEnabled() && this.logger.getLogFileName()) { - args.push(Arguments.LogFile, combinePaths(getDirectoryPath(normalizeSlashes(this.logger.getLogFileName())), `ti-${process.pid}.log`)); - } - if (this.typingSafeListLocation) { - args.push(Arguments.TypingSafeListLocation, this.typingSafeListLocation); - } - if (this.typesMapLocation) { - args.push(Arguments.TypesMapLocation, this.typesMapLocation); - } - if (this.npmLocation) { - args.push(Arguments.NpmLocation, this.npmLocation); - } - if (this.validateDefaultNpmLocation) { - args.push(Arguments.ValidateDefaultNpmLocation); - } - - const execArgv: string[] = []; - for (const arg of process.execArgv) { - const match = /^--((?:debug|inspect)(?:-brk)?)(?:=(\d+))?$/.exec(arg); - if (match) { - // if port is specified - use port + 1 - // otherwise pick a default port depending on if 'debug' or 'inspect' and use its value + 1 - const currentPort = match[2] !== undefined - ? +match[2] - : match[1].charAt(0) === "d" ? 5858 : 9229; - execArgv.push(`--${match[1]}=${currentPort + 1}`); - break; - } - } - - this.installer = childProcess.fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv }); - this.installer.on("message", m => this.handleMessage(m)); - - // We have to schedule this event to the next tick - // cause this fn will be called during - // new IOSession => super(which is Session) => new ProjectService => NodeTypingsInstaller.attach - // and if "event" is referencing "this" before super class is initialized, it will be a ReferenceError in ES6 class. - this.host.setImmediate(() => this.event({ pid: this.installer.pid }, "typingsInstallerPid")); - - process.on("exit", () => { - this.installer.kill(); - }); - } - - onProjectClosed(p: Project): void { - this.send({ projectName: p.getProjectName(), kind: "closeProject" }); - } - - private send(rq: T): void { - this.installer.send(rq); - } - - enqueueInstallTypingsRequest(project: Project, typeAcquisition: TypeAcquisition, unresolvedImports: SortedReadonlyArray): void { - const request = createInstallTypingsRequest(project, typeAcquisition, unresolvedImports); - if (this.logger.hasLevel(LogLevel.verbose)) { - if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`Scheduling throttled operation:${stringifyIndented(request)}`); - } - } - - const operationId = project.getProjectName(); - const operation = () => { - if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`Sending request:${stringifyIndented(request)}`); - } - this.send(request); - }; - const queuedRequest: QueuedOperation = { operationId, operation }; - - if (this.activeRequestCount < NodeTypingsInstaller.maxActiveRequestCount) { - this.scheduleRequest(queuedRequest); - } - else { - if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`Deferring request for: ${operationId}`); - } - this.requestQueue.push(queuedRequest); - this.requestMap.set(operationId, queuedRequest); - } - } - - private handleMessage(response: TypesRegistryResponse | PackageInstalledResponse | SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse) { - if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`Received response:${stringifyIndented(response)}`); - } - - switch (response.kind) { - case EventTypesRegistry: - this.typesRegistryCache = new Map(getEntries(response.typesRegistry)); - break; - case ActionPackageInstalled: { - const { success, message } = response; - if (success) { - this.packageInstalledPromise!.resolve({ successMessage: message }); - } - else { - this.packageInstalledPromise!.reject(message); - } - this.packageInstalledPromise = undefined; - - this.projectService.updateTypingsForProject(response); - - // The behavior is the same as for setTypings, so send the same event. - this.event(response, "setTypings"); - break; - } - case EventInitializationFailed: { - const body: protocol.TypesInstallerInitializationFailedEventBody = { - message: response.message - }; - const eventName: protocol.TypesInstallerInitializationFailedEventName = "typesInstallerInitializationFailed"; - this.event(body, eventName); - break; - } - case EventBeginInstallTypes: { - const body: protocol.BeginInstallTypesEventBody = { - eventId: response.eventId, - packages: response.packagesToInstall, - }; - const eventName: protocol.BeginInstallTypesEventName = "beginInstallTypes"; - this.event(body, eventName); - break; - } - case EventEndInstallTypes: { - if (this.telemetryEnabled) { - const body: protocol.TypingsInstalledTelemetryEventBody = { - telemetryEventName: "typingsInstalled", - payload: { - installedPackages: response.packagesToInstall.join(","), - installSuccess: response.installSuccess, - typingsInstallerVersion: response.typingsInstallerVersion - } - }; - const eventName: protocol.TelemetryEventName = "telemetry"; - this.event(body, eventName); - } - - const body: protocol.EndInstallTypesEventBody = { - eventId: response.eventId, - packages: response.packagesToInstall, - success: response.installSuccess, - }; - const eventName: protocol.EndInstallTypesEventName = "endInstallTypes"; - this.event(body, eventName); - break; - } - case ActionInvalidate: { - this.projectService.updateTypingsForProject(response); - break; - } - case ActionSet: { - if (this.activeRequestCount > 0) { - this.activeRequestCount--; - } - else { - Debug.fail("Received too many responses"); - } - - while (this.requestQueue.length > 0) { - const queuedRequest = this.requestQueue.shift()!; - if (this.requestMap.get(queuedRequest.operationId) === queuedRequest) { - this.requestMap.delete(queuedRequest.operationId); - this.scheduleRequest(queuedRequest); - break; - } - - if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`Skipping defunct request for: ${queuedRequest.operationId}`); - } - } - - this.projectService.updateTypingsForProject(response); - - this.event(response, "setTypings"); - - break; - } - default: - assertType(response); - } - } - - private scheduleRequest(request: QueuedOperation) { - if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`Scheduling request for: ${request.operationId}`); - } - this.activeRequestCount++; - this.host.setTimeout(request.operation, NodeTypingsInstaller.requestDelayMillis); - } - } - - class IOSession extends Session { - private eventPort: number | undefined; - private eventSocket: NodeSocket | undefined; - private socketEventQueue: { body: any, eventName: string }[] | undefined; - /** No longer needed if syntax target is es6 or above. Any access to "this" before initialized will be a runtime error. */ - private constructed: boolean | undefined; - - constructor() { - const event = (body: object, eventName: string) => { - this.event(body, eventName); - }; - - const host = sys; - - const typingsInstaller = disableAutomaticTypingAcquisition - ? undefined - : new NodeTypingsInstaller(telemetryEnabled, logger, host, getGlobalTypingsCacheLocation(), typingSafeListLocation, typesMapLocation, npmLocation, validateDefaultNpmLocation, event); - - super({ - host, - cancellationToken, - useSingleInferredProject, - useInferredProjectPerProjectRoot, - typingsInstaller: typingsInstaller || nullTypingsInstaller, - byteLength: Buffer.byteLength, - hrtime: process.hrtime, - logger, - canUseEvents: true, - suppressDiagnosticEvents, - syntaxOnly, - serverMode, - noGetErrOnBackgroundUpdate, - globalPlugins, - pluginProbeLocations, - allowLocalPluginLoads, - typesMapLocation, - }); - - this.eventPort = eventPort; - if (this.canUseEvents && this.eventPort) { - const s = net.connect({ port: this.eventPort }, () => { - this.eventSocket = s; - if (this.socketEventQueue) { - // flush queue. - for (const event of this.socketEventQueue) { - this.writeToEventSocket(event.body, event.eventName); - } - this.socketEventQueue = undefined; - } - }); - } - - this.constructed = true; - } - - event(body: T, eventName: string): void { - Debug.assert(!!this.constructed, "Should only call `IOSession.prototype.event` on an initialized IOSession"); - - if (this.canUseEvents && this.eventPort) { - if (!this.eventSocket) { - if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`eventPort: event "${eventName}" queued, but socket not yet initialized`); - } - (this.socketEventQueue || (this.socketEventQueue = [])).push({ body, eventName }); - return; - } - else { - Debug.assert(this.socketEventQueue === undefined); - this.writeToEventSocket(body, eventName); - } - } - else { - super.event(body, eventName); - } - } - - private writeToEventSocket(body: object, eventName: string): void { - this.eventSocket!.write(formatMessage(toEvent(eventName, body), this.logger, this.byteLength, this.host.newLine), "utf8"); - } - - exit() { - this.logger.info("Exiting..."); - this.projectService.closeLog(); - if (traceDir) { - tracing.stopTracing(ts.emptyArray); - } - process.exit(0); - } - - listen() { - rl.on("line", (input: string) => { - const message = input.trim(); - this.onMessage(message); - }); - - rl.on("close", () => { - this.exit(); - }); - } - } - - interface LogOptions { - file?: string; - detailLevel?: LogLevel; - traceToConsole?: boolean; - logToFile?: boolean; - } - - function parseLoggingEnvironmentString(logEnvStr: string | undefined): LogOptions { - if (!logEnvStr) { - return {}; - } - const logEnv: LogOptions = { logToFile: true }; - const args = logEnvStr.split(" "); - const len = args.length - 1; - for (let i = 0; i < len; i += 2) { - const option = args[i]; - const { value, extraPartCounter } = getEntireValue(i + 1); - i += extraPartCounter; - if (option && value) { - switch (option) { - case "-file": - logEnv.file = value; - break; - case "-level": - const level = getLogLevel(value); - logEnv.detailLevel = level !== undefined ? level : LogLevel.normal; - break; - case "-traceToConsole": - logEnv.traceToConsole = value.toLowerCase() === "true"; - break; - case "-logToFile": - logEnv.logToFile = value.toLowerCase() === "true"; - break; - } - } - } - return logEnv; - - function getEntireValue(initialIndex: number) { - let pathStart = args[initialIndex]; - let extraPartCounter = 0; - if (pathStart.charCodeAt(0) === CharacterCodes.doubleQuote && - pathStart.charCodeAt(pathStart.length - 1) !== CharacterCodes.doubleQuote) { - for (let i = initialIndex + 1; i < args.length; i++) { - pathStart += " "; - pathStart += args[i]; - extraPartCounter++; - if (pathStart.charCodeAt(pathStart.length - 1) === CharacterCodes.doubleQuote) break; - } - } - return { value: stripQuotes(pathStart), extraPartCounter }; + declare const addEventListener: any; + declare const removeEventListener: any; + function findArgumentStringArray(argName: string): readonly string[] { + const arg = findArgument(argName); + if (arg === undefined) { + return emptyArray; } + return arg.split(",").filter(name => name !== ""); } - function getLogLevel(level: string | undefined) { + export function getLogLevel(level: string | undefined) { if (level) { const l = level.toLowerCase(); for (const name in LogLevel) { @@ -648,375 +22,70 @@ namespace ts.server { return undefined; } - // TSS_LOG "{ level: "normal | verbose | terse", file?: string}" - function createLogger() { - const cmdLineLogFileName = findArgument("--logFile"); - const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); - const envLogOptions = parseLoggingEnvironmentString(process.env.TSS_LOG); - - const unsubstitutedLogFileName = cmdLineLogFileName - ? stripQuotes(cmdLineLogFileName) - : envLogOptions.logToFile - ? envLogOptions.file || (__dirname + "/.log" + process.pid.toString()) - : undefined; - - const substitutedLogFileName = unsubstitutedLogFileName - ? unsubstitutedLogFileName.replace("PID", process.pid.toString()) - : undefined; - - const logVerbosity = cmdLineVerbosity || envLogOptions.detailLevel; - return new Logger(substitutedLogFileName!, envLogOptions.traceToConsole!, logVerbosity!); // TODO: GH#18217 - } - // This places log file in the directory containing editorServices.js - // TODO: check that this location is writable - - // average async stat takes about 30 microseconds - // set chunk size to do 30 files in < 1 millisecond - function createPollingWatchedFileSet(interval = 2500, chunkSize = 30) { - const watchedFiles: WatchedFile[] = []; - let nextFileToCheck = 0; - return { getModifiedTime, poll, startWatchTimer, addFile, removeFile }; - - function getModifiedTime(fileName: string): Date { - // Caller guarantees that `fileName` exists, so there'd be no benefit from throwIfNoEntry - return fs.statSync(fileName).mtime; - } - - function poll(checkedIndex: number) { - const watchedFile = watchedFiles[checkedIndex]; - if (!watchedFile) { - return; - } - - fs.stat(watchedFile.fileName, (err, stats) => { - if (err) { - if (err.code === "ENOENT") { - if (watchedFile.mtime.getTime() !== 0) { - watchedFile.mtime = missingFileModifiedTime; - watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Deleted); - } - } - else { - watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Changed); - } - } - else { - onWatchedFileStat(watchedFile, stats.mtime); - } - }); - } - - // this implementation uses polling and - // stat due to inconsistencies of fs.watch - // and efficiency of stat on modern filesystems - function startWatchTimer() { - // eslint-disable-next-line no-restricted-globals - setInterval(() => { - let count = 0; - let nextToCheck = nextFileToCheck; - let firstCheck = -1; - while ((count < chunkSize) && (nextToCheck !== firstCheck)) { - poll(nextToCheck); - if (firstCheck < 0) { - firstCheck = nextToCheck; - } - nextToCheck++; - if (nextToCheck === watchedFiles.length) { - nextToCheck = 0; - } - count++; - } - nextFileToCheck = nextToCheck; - }, interval); - } - - function addFile(fileName: string, callback: FileWatcherCallback): WatchedFile { - const file: WatchedFile = { - fileName, - callback, - mtime: sys.fileExists(fileName) - ? getModifiedTime(fileName) - : missingFileModifiedTime // Any subsequent modification will occur after this time - }; - - watchedFiles.push(file); - if (watchedFiles.length === 1) { - startWatchTimer(); - } - return file; - } - - function removeFile(file: WatchedFile) { - unorderedRemoveItem(watchedFiles, file); - } + export interface StartInput { + args: readonly string[]; + logger: Logger; + cancellationToken: ServerCancellationToken; + serverMode: LanguageServiceMode | undefined; + unknownServerMode?: string; + startSession: (option: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) => void; } + function start({ args, logger, cancellationToken, serverMode, unknownServerMode, startSession: startServer }: StartInput, platform: string) { + const syntaxOnly = hasArgument("--syntaxOnly"); - // REVIEW: for now this implementation uses polling. - // The advantage of polling is that it works reliably - // on all os and with network mounted files. - // For 90 referenced files, the average time to detect - // changes is 2*msInterval (by default 5 seconds). - // The overhead of this is .04 percent (1/2500) with - // average pause of < 1 millisecond (and max - // pause less than 1.5 milliseconds); question is - // do we anticipate reference sets in the 100s and - // do we care about waiting 10-20 seconds to detect - // changes for large reference sets? If so, do we want - // to increase the chunk size or decrease the interval - // time dynamically to match the large reference set? - const pollingWatchedFileSet = createPollingWatchedFileSet(); - - const pending: Buffer[] = []; - let canWrite = true; + logger.info(`Starting TS Server`); + logger.info(`Version: ${version}`); + logger.info(`Arguments: ${args.join(" ")}`); + logger.info(`Platform: ${platform} NodeVersion: ${getNodeMajorVersion()} CaseSensitive: ${sys.useCaseSensitiveFileNames}`); + logger.info(`ServerMode: ${serverMode} syntaxOnly: ${syntaxOnly} hasUnknownServerMode: ${unknownServerMode}`); - function writeMessage(buf: Buffer) { - if (!canWrite) { - pending.push(buf); - } - else { - canWrite = false; - process.stdout.write(buf, setCanWriteFlagAndWriteMessageIfNecessary); - } - } + setStackTraceLimit(); - function setCanWriteFlagAndWriteMessageIfNecessary() { - canWrite = true; - if (pending.length) { - writeMessage(pending.shift()!); + if (Debug.isDebugging) { + Debug.enableDebugInfo(); } - } - function extractWatchDirectoryCacheKey(path: string, currentDriveKey: string | undefined) { - path = normalizeSlashes(path); - if (isUNCPath(path)) { - // UNC path: extract server name - // //server/location - // ^ <- from 0 to this position - const firstSlash = path.indexOf(directorySeparator, 2); - return firstSlash !== -1 ? toFileNameLowerCase(path.substring(0, firstSlash)) : path; + if (sys.tryEnableSourceMapsForHost && /^development$/i.test(sys.getEnvironmentVariable("NODE_ENV"))) { + sys.tryEnableSourceMapsForHost(); } - const rootLength = getRootLength(path); - if (rootLength === 0) { - // relative path - assume file is on the current drive - return currentDriveKey; - } - if (path.charCodeAt(1) === CharacterCodes.colon && path.charCodeAt(2) === CharacterCodes.slash) { - // rooted path that starts with c:/... - extract drive letter - return toFileNameLowerCase(path.charAt(0)); - } - if (path.charCodeAt(0) === CharacterCodes.slash && path.charCodeAt(1) !== CharacterCodes.slash) { - // rooted path that starts with slash - /somename - use key for current drive - return currentDriveKey; - } - // do not cache any other cases - return undefined; - } - - function isUNCPath(s: string): boolean { - return s.length > 2 && s.charCodeAt(0) === CharacterCodes.slash && s.charCodeAt(1) === CharacterCodes.slash; - } - const logger = createLogger(); + // Overwrites the current console messages to instead write to + // the log. This is so that language service plugins which use + // console.log don't break the message passing between tsserver + // and the client + console.log = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(", "), Msg.Info); + console.warn = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(", "), Msg.Err); + console.error = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(", "), Msg.Err); - const sys = ts.sys; - const nodeVersion = getNodeMajorVersion(); - // use watchGuard process on Windows when node version is 4 or later - const useWatchGuard = process.platform === "win32" && nodeVersion! >= 4; - const originalWatchDirectory: ServerHost["watchDirectory"] = sys.watchDirectory.bind(sys); - const noopWatcher: FileWatcher = { close: noop }; - // This is the function that catches the exceptions when watching directory, and yet lets project service continue to function - // Eg. on linux the number of watches are limited and one could easily exhaust watches and the exception ENOSPC is thrown when creating watcher at that point - function watchDirectorySwallowingException(path: string, callback: DirectoryWatcherCallback, recursive?: boolean, options?: WatchOptions): FileWatcher { - try { - return originalWatchDirectory(path, callback, recursive, options); - } - catch (e) { - logger.info(`Exception when creating directory watcher: ${e.message}`); - return noopWatcher; - } + startServer( + { + globalPlugins: findArgumentStringArray("--globalPlugins"), + pluginProbeLocations: findArgumentStringArray("--pluginProbeLocations"), + allowLocalPluginLoads: hasArgument("--allowLocalPluginLoads"), + useSingleInferredProject: hasArgument("--useSingleInferredProject"), + useInferredProjectPerProjectRoot: hasArgument("--useInferredProjectPerProjectRoot"), + suppressDiagnosticEvents: hasArgument("--suppressDiagnosticEvents"), + noGetErrOnBackgroundUpdate: hasArgument("--noGetErrOnBackgroundUpdate"), + syntaxOnly, + serverMode + }, + logger, + cancellationToken + ); } - if (useWatchGuard) { - const currentDrive = extractWatchDirectoryCacheKey(sys.resolvePath(sys.getCurrentDirectory()), /*currentDriveKey*/ undefined); - const statusCache = new Map(); - sys.watchDirectory = (path, callback, recursive, options) => { - const cacheKey = extractWatchDirectoryCacheKey(path, currentDrive); - let status = cacheKey && statusCache.get(cacheKey); - if (status === undefined) { - if (logger.hasLevel(LogLevel.verbose)) { - logger.info(`${cacheKey} for path ${path} not found in cache...`); - } - try { - const args = [combinePaths(__dirname, "watchGuard.js"), path]; - if (logger.hasLevel(LogLevel.verbose)) { - logger.info(`Starting ${process.execPath} with args:${stringifyIndented(args)}`); - } - childProcess.execFileSync(process.execPath, args, { stdio: "ignore", env: { ELECTRON_RUN_AS_NODE: "1" } }); - status = true; - if (logger.hasLevel(LogLevel.verbose)) { - logger.info(`WatchGuard for path ${path} returned: OK`); - } - } - catch (e) { - status = false; - if (logger.hasLevel(LogLevel.verbose)) { - logger.info(`WatchGuard for path ${path} returned: ${e.message}`); - } - } - if (cacheKey) { - statusCache.set(cacheKey, status); - } - } - else if (logger.hasLevel(LogLevel.verbose)) { - logger.info(`watchDirectory for ${path} uses cached drive information.`); - } - if (status) { - // this drive is safe to use - call real 'watchDirectory' - return watchDirectorySwallowingException(path, callback, recursive, options); - } - else { - // this drive is unsafe - return no-op watcher - return noopWatcher; - } - }; + setStackTraceLimit(); + // Cannot check process var directory in webworker so has to be typeof check here + if (typeof process !== "undefined") { + start(initializeNodeSystem(), require("os").platform()); } else { - sys.watchDirectory = watchDirectorySwallowingException; - } - - // Override sys.write because fs.writeSync is not reliable on Node 4 - sys.write = (s: string) => writeMessage(sys.bufferFrom!(s, "utf8") as globalThis.Buffer); - sys.watchFile = (fileName, callback) => { - const watchedFile = pollingWatchedFileSet.addFile(fileName, callback); - return { - close: () => pollingWatchedFileSet.removeFile(watchedFile) + // Get args from first message + const listener = (e: any) => { + removeEventListener("message", listener); + const args = e.data; + start(initializeWebSystem(args), "web"); }; - }; - - /* eslint-disable no-restricted-globals */ - sys.setTimeout = setTimeout; - sys.clearTimeout = clearTimeout; - sys.setImmediate = setImmediate; - sys.clearImmediate = clearImmediate; - /* eslint-enable no-restricted-globals */ - - if (typeof global !== "undefined" && global.gc) { - sys.gc = () => global.gc(); - } - - sys.require = (initialDir: string, moduleName: string): RequireResult => { - try { - return { module: require(resolveJSModule(moduleName, initialDir, sys)), error: undefined }; - } - catch (error) { - return { module: undefined, error }; - } - }; - - let cancellationToken: ServerCancellationToken; - try { - const factory = require("./cancellationToken"); - cancellationToken = factory(sys.args); - } - catch (e) { - cancellationToken = nullCancellationToken; - } - - function parseEventPort(eventPortStr: string | undefined) { - const eventPort = eventPortStr === undefined ? undefined : parseInt(eventPortStr); - return eventPort !== undefined && !isNaN(eventPort) ? eventPort : undefined; - } - const eventPort: number | undefined = parseEventPort(findArgument("--eventPort")); - - const localeStr = findArgument("--locale"); - if (localeStr) { - validateLocaleAndSetLanguage(localeStr, sys); - } - - setStackTraceLimit(); - - const typingSafeListLocation = findArgument(Arguments.TypingSafeListLocation)!; // TODO: GH#18217 - const typesMapLocation = findArgument(Arguments.TypesMapLocation) || combinePaths(getDirectoryPath(sys.getExecutingFilePath()), "typesMap.json"); - const npmLocation = findArgument(Arguments.NpmLocation); - const validateDefaultNpmLocation = hasArgument(Arguments.ValidateDefaultNpmLocation); - - function parseStringArray(argName: string): readonly string[] { - const arg = findArgument(argName); - if (arg === undefined) { - return emptyArray; - } - return arg.split(",").filter(name => name !== ""); + addEventListener("message", listener); } - - let unknownServerMode: string | undefined; - function parseServerMode(): LanguageServiceMode | undefined { - const mode = findArgument("--serverMode"); - if (mode === undefined) { - return undefined; - } - - switch (mode.toLowerCase()) { - case "semantic": - return LanguageServiceMode.Semantic; - case "partialsemantic": - return LanguageServiceMode.PartialSemantic; - case "syntactic": - return LanguageServiceMode.Syntactic; - default: - unknownServerMode = mode; - return undefined; - } - } - - const globalPlugins = parseStringArray("--globalPlugins"); - const pluginProbeLocations = parseStringArray("--pluginProbeLocations"); - const allowLocalPluginLoads = hasArgument("--allowLocalPluginLoads"); - - const useSingleInferredProject = hasArgument("--useSingleInferredProject"); - const useInferredProjectPerProjectRoot = hasArgument("--useInferredProjectPerProjectRoot"); - const disableAutomaticTypingAcquisition = hasArgument("--disableAutomaticTypingAcquisition"); - const suppressDiagnosticEvents = hasArgument("--suppressDiagnosticEvents"); - const syntaxOnly = hasArgument("--syntaxOnly"); - const serverMode = parseServerMode(); - const telemetryEnabled = hasArgument(Arguments.EnableTelemetry); - const noGetErrOnBackgroundUpdate = hasArgument("--noGetErrOnBackgroundUpdate"); - - const commandLineTraceDir = findArgument("--traceDirectory"); - const traceDir = commandLineTraceDir - ? stripQuotes(commandLineTraceDir) - : process.env.TSS_TRACE; - if (traceDir) { - tracing.startTracing(tracing.Mode.Server, traceDir); - } - - logger.info(`Starting TS Server`); - logger.info(`Version: ${version}`); - logger.info(`Arguments: ${process.argv.join(" ")}`); - logger.info(`Platform: ${os.platform()} NodeVersion: ${nodeVersion} CaseSensitive: ${sys.useCaseSensitiveFileNames}`); - logger.info(`ServerMode: ${serverMode} syntaxOnly: ${syntaxOnly} hasUnknownServerMode: ${unknownServerMode}`); - - const ioSession = new IOSession(); - process.on("uncaughtException", err => { - ioSession.logError(err, "unknown"); - }); - // See https://github.com/Microsoft/TypeScript/issues/11348 - (process as any).noAsar = true; - // Start listening - ioSession.listen(); - - if (Debug.isDebugging) { - Debug.enableDebugInfo(); - } - - if (ts.sys.tryEnableSourceMapsForHost && /^development$/i.test(ts.sys.getEnvironmentVariable("NODE_ENV"))) { - ts.sys.tryEnableSourceMapsForHost(); - } - - // Overwrites the current console messages to instead write to - // the log. This is so that language service plugins which use - // console.log don't break the message passing between tsserver - // and the client - console.log = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(", "), Msg.Info); - console.warn = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(", "), Msg.Err); - console.error = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(", "), Msg.Err); } diff --git a/src/tsserver/tsconfig.json b/src/tsserver/tsconfig.json index 85412bd0fb5bb..6643ecf2f1b7a 100644 --- a/src/tsserver/tsconfig.json +++ b/src/tsserver/tsconfig.json @@ -8,6 +8,8 @@ ] }, "files": [ + "nodeServer.ts", + "webServer.ts", "server.ts" ], "references": [ @@ -15,6 +17,7 @@ { "path": "../services", "prepend": true }, { "path": "../jsTyping", "prepend": true }, { "path": "../server", "prepend": true }, + { "path": "../webServer", "prepend": true }, { "path": "../deprecatedCompat", "prepend": true } ] } diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts new file mode 100644 index 0000000000000..dced32152b75b --- /dev/null +++ b/src/tsserver/webServer.ts @@ -0,0 +1,127 @@ +/*@internal*/ +namespace ts.server { + declare const addEventListener: any; + declare const postMessage: any; + declare const close: any; + declare const location: any; + declare const XMLHttpRequest: any; + declare const self: any; + + const nullLogger: Logger = { + close: noop, + hasLevel: returnFalse, + loggingEnabled: returnFalse, + perftrc: noop, + info: noop, + msg: noop, + startGroup: noop, + endGroup: noop, + getLogFileName: returnUndefined, + }; + + function parseServerMode(): LanguageServiceMode | string | undefined { + const mode = findArgument("--serverMode"); + if (!mode) return undefined; + switch (mode.toLowerCase()) { + case "partialsemantic": + return LanguageServiceMode.PartialSemantic; + case "syntactic": + return LanguageServiceMode.Syntactic; + default: + return mode; + } + } + + export function initializeWebSystem(args: string[]): StartInput { + createWebSystem(args); + const modeOrUnknown = parseServerMode(); + let serverMode: LanguageServiceMode | undefined; + let unknownServerMode: string | undefined; + if (typeof modeOrUnknown === "number") serverMode = modeOrUnknown; + else unknownServerMode = modeOrUnknown; + return { + args, + logger: createLogger(), + cancellationToken: nullCancellationToken, + // Webserver defaults to partial semantic mode + serverMode: serverMode ?? LanguageServiceMode.PartialSemantic, + unknownServerMode, + startSession: startWebSession + }; + } + + function createLogger() { + const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); + return cmdLineVerbosity !== undefined ? new MainProcessLogger(cmdLineVerbosity, { writeMessage }) : nullLogger; + } + + function writeMessage(s: any) { + postMessage(s); + } + + function createWebSystem(args: string[]) { + Debug.assert(ts.sys === undefined); + const webHost: WebHost = { + readFile: webPath => { + const request = new XMLHttpRequest(); + request.open("GET", webPath, /* asynchronous */ false); + request.send(); + return request.status === 200 ? request.responseText : undefined; + }, + fileExists: webPath => { + const request = new XMLHttpRequest(); + request.open("HEAD", webPath, /* asynchronous */ false); + request.send(); + return request.status === 200; + }, + writeMessage, + }; + // Do this after sys has been set as findArguments is going to work only then + const sys = server.createWebSystem(webHost, args, () => findArgument("--executingFilePath") || location + ""); + ts.sys = sys; + const localeStr = findArgument("--locale"); + if (localeStr) { + validateLocaleAndSetLanguage(localeStr, sys); + } + } + + function hrtime(previous?: [number, number]) { + const now = self.performance.now(performance) * 1e-3; + let seconds = Math.floor(now); + let nanoseconds = Math.floor((now % 1) * 1e9); + if (previous) { + seconds = seconds - previous[0]; + nanoseconds = nanoseconds - previous[1]; + if (nanoseconds < 0) { + seconds--; + nanoseconds += 1e9; + } + } + return [seconds, nanoseconds]; + } + + function startWebSession(options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) { + class WorkerSession extends server.WorkerSession { + constructor() { + super(sys as ServerHost, { writeMessage }, options, logger, cancellationToken, hrtime); + } + + exit() { + this.logger.info("Exiting..."); + this.projectService.closeLog(); + close(0); + } + + listen() { + addEventListener("message", (message: any) => { + this.onMessage(message.data); + }); + } + } + + const session = new WorkerSession(); + + // Start listening + session.listen(); + } +} diff --git a/src/webServer/tsconfig.json b/src/webServer/tsconfig.json new file mode 100644 index 0000000000000..9d0e1297f583c --- /dev/null +++ b/src/webServer/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig-base", + "compilerOptions": { + "removeComments": false, + "outFile": "../../built/local/webServer.js", + "preserveConstEnums": true, + "types": [ + "node" + ] + }, + "references": [ + { "path": "../compiler" }, + { "path": "../jsTyping" }, + { "path": "../services" }, + { "path": "../server" } + ], + "files": [ + "webServer.ts", + ] +} diff --git a/src/webServer/webServer.ts b/src/webServer/webServer.ts new file mode 100644 index 0000000000000..df149ed3460eb --- /dev/null +++ b/src/webServer/webServer.ts @@ -0,0 +1,216 @@ +/*@internal*/ +namespace ts.server { + export interface HostWithWriteMessage { + writeMessage(s: any): void; + } + export interface WebHost extends HostWithWriteMessage { + readFile(path: string): string | undefined; + fileExists(path: string): boolean; + } + + export class BaseLogger implements Logger { + private seq = 0; + private inGroup = false; + private firstInGroup = true; + constructor(protected readonly level: LogLevel) { + } + static padStringRight(str: string, padding: string) { + return (str + padding).slice(0, padding.length); + } + close() { + } + getLogFileName(): string | undefined { + return undefined; + } + perftrc(s: string) { + this.msg(s, Msg.Perf); + } + info(s: string) { + this.msg(s, Msg.Info); + } + err(s: string) { + this.msg(s, Msg.Err); + } + startGroup() { + this.inGroup = true; + this.firstInGroup = true; + } + endGroup() { + this.inGroup = false; + } + loggingEnabled() { + return true; + } + hasLevel(level: LogLevel) { + return this.loggingEnabled() && this.level >= level; + } + msg(s: string, type: Msg = Msg.Err) { + switch (type) { + case Msg.Info: + perfLogger.logInfoEvent(s); + break; + case Msg.Perf: + perfLogger.logPerfEvent(s); + break; + default: // Msg.Err + perfLogger.logErrEvent(s); + break; + } + + if (!this.canWrite()) return; + + s = `[${nowString()}] ${s}\n`; + if (!this.inGroup || this.firstInGroup) { + const prefix = BaseLogger.padStringRight(type + " " + this.seq.toString(), " "); + s = prefix + s; + } + this.write(s, type); + if (!this.inGroup) { + this.seq++; + } + } + protected canWrite() { + return true; + } + protected write(_s: string, _type: Msg) { + } + } + + export type MessageLogLevel = "info" | "perf" | "error"; + export interface LoggingMessage { + readonly type: "log"; + readonly level: MessageLogLevel; + readonly body: string + } + export class MainProcessLogger extends BaseLogger { + constructor(level: LogLevel, private host: HostWithWriteMessage) { + super(level); + } + protected write(body: string, type: Msg) { + let level: MessageLogLevel; + switch (type) { + case Msg.Info: + level = "info"; + break; + case Msg.Perf: + level = "perf"; + break; + case Msg.Err: + level = "error"; + break; + default: + Debug.assertNever(type); + } + this.host.writeMessage({ + type: "log", + level, + body, + }); + } + } + + export function createWebSystem(host: WebHost, args: string[], getExecutingFilePath: () => string): ServerHost { + const returnEmptyString = () => ""; + const getExecutingDirectoryPath = memoize(() => memoize(() => ensureTrailingDirectorySeparator(getDirectoryPath(getExecutingFilePath())))); + // Later we could map ^memfs:/ to do something special if we want to enable more functionality like module resolution or something like that + const getWebPath = (path: string) => startsWith(path, directorySeparator) ? path.replace(directorySeparator, getExecutingDirectoryPath()) : undefined; + return { + args, + newLine: "\r\n", // This can be configured by clients + useCaseSensitiveFileNames: false, // Use false as the default on web since that is the safest option + readFile: path => { + const webPath = getWebPath(path); + return webPath && host.readFile(webPath); + }, + + write: host.writeMessage.bind(host), + watchFile: returnNoopFileWatcher, + watchDirectory: returnNoopFileWatcher, + + getExecutingFilePath: () => directorySeparator, + getCurrentDirectory: returnEmptyString, // For inferred project root if projectRoot path is not set, normalizing the paths + + /* eslint-disable no-restricted-globals */ + setTimeout, + clearTimeout, + setImmediate: x => setTimeout(x, 0), + clearImmediate: clearTimeout, + /* eslint-enable no-restricted-globals */ + + require: () => ({ module: undefined, error: new Error("Not implemented") }), + exit: notImplemented, + + // Debugging related + getEnvironmentVariable: returnEmptyString, // TODO:: Used to enable debugging info + // tryEnableSourceMapsForHost?(): void; + // debugMode?: boolean; + + // For semantic server mode + fileExists: path => { + const webPath = getWebPath(path); + return !!webPath && host.fileExists(webPath); + }, + directoryExists: returnFalse, // Module resolution + readDirectory: notImplemented, // Configured project, typing installer + getDirectories: () => [], // For automatic type reference directives + createDirectory: notImplemented, // compile On save + writeFile: notImplemented, // compile on save + resolvePath: identity, // Plugins + // realpath? // Module resolution, symlinks + // getModifiedTime // File watching + // createSHA256Hash // telemetry of the project + + // Logging related + // /*@internal*/ bufferFrom?(input: string, encoding?: string): Buffer; + // gc?(): void; + // getMemoryUsage?(): number; + }; + } + + export interface StartSessionOptions { + globalPlugins: SessionOptions["globalPlugins"]; + pluginProbeLocations: SessionOptions["pluginProbeLocations"]; + allowLocalPluginLoads: SessionOptions["allowLocalPluginLoads"]; + useSingleInferredProject: SessionOptions["useSingleInferredProject"]; + useInferredProjectPerProjectRoot: SessionOptions["useInferredProjectPerProjectRoot"]; + suppressDiagnosticEvents: SessionOptions["suppressDiagnosticEvents"]; + noGetErrOnBackgroundUpdate: SessionOptions["noGetErrOnBackgroundUpdate"]; + syntaxOnly: SessionOptions["syntaxOnly"]; + serverMode: SessionOptions["serverMode"]; + } + export class WorkerSession extends Session<{}> { + constructor(host: ServerHost, private webHost: HostWithWriteMessage, options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken, hrtime: SessionOptions["hrtime"]) { + super({ + host, + cancellationToken, + ...options, + typingsInstaller: nullTypingsInstaller, + byteLength: notImplemented, // Formats the message text in send of Session which is overriden in this class so not needed + hrtime, + logger, + canUseEvents: false, + }); + } + + public send(msg: protocol.Message) { + if (msg.type === "event" && !this.canUseEvents) { + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`Session does not support events: ignored event: ${JSON.stringify(msg)}`); + } + return; + } + if (this.logger.hasLevel(LogLevel.verbose)) { + this.logger.info(`${msg.type}:${indent(JSON.stringify(msg))}`); + } + this.webHost.writeMessage(msg); + } + + protected parseMessage(message: {}): protocol.Request { + return message; + } + + protected toStringMessage(message: {}) { + return JSON.stringify(message, undefined, 2); + } + } +} diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index c269e0f0b0be2..077aba3b528d0 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -9909,7 +9909,7 @@ declare namespace ts.server { allowLocalPluginLoads?: boolean; typesMapLocation?: string; } - class Session implements EventSender { + class Session implements EventSender { private readonly gcTimer; protected projectService: ProjectService; private changeSeq; @@ -10067,7 +10067,9 @@ declare namespace ts.server { private resetCurrentRequest; executeWithRequestId(requestId: number, f: () => T): T; executeCommand(request: protocol.Request): HandlerResponse; - onMessage(message: string): void; + onMessage(message: TMessage): void; + protected parseMessage(message: TMessage): protocol.Request; + protected toStringMessage(message: TMessage): string; private getFormatOptions; private getPreferences; private getHostFormatOptions;