From e50aedc2fb6e1c799201b1c58d1972db9454b8c6 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Sat, 18 Jul 2020 12:58:13 -0700 Subject: [PATCH 01/26] Adds experimental support for running TS Server in a web worker This change makes it possible to run a syntax old TS server in a webworker. This is will let serverless versions of VS Code web run the TypeScript extension with minimal changes. As the diff on `server.ts` is difficult to parse, here's an overview of the changes: - Introduce the concept of a `Runtime`. Valid values are `Node` and `Web`. - Move calls to `require` into the functions that use these modules - Wrap existing server logic into `startNodeServer` - Introduce web server with `startWebServer`. This uses a `WorkerSession` - Add a custom version of `ts.sys` for web - Have the worker server start when it is passed an array of arguments in a message In order to make the server logic more clear, this change also tries to reduce the reliance on closures and better group function declarations vs the server spawning logic. **Next Steps** I'd like someone from the TS team to help get these changes into a shippable state. This will involve: - Adddress todo comments - Code cleanup - Make sure these changes do not regress node servers - Determine if we should add a new `tsserver.web.js` file instead of having the web worker logic all live in `tsserver.js` --- src/server/session.ts | 16 +- src/tsserver/server.ts | 1430 ++++++++++++++++++++++------------------ 2 files changed, 802 insertions(+), 644 deletions(-) diff --git a/src/server/session.ts b/src/server/session.ts index 491b0bf3f7f12..4a5347e2f9a5e 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -679,7 +679,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; @@ -2894,7 +2894,7 @@ namespace ts.server { } } - public onMessage(message: string) { + public onMessage(message: MessageType) { this.gcTimer.scheduleCollect(); this.performanceData = undefined; @@ -2903,17 +2903,17 @@ 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(message.toString())}`); } } 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; - perfLogger.logStartCommand("" + request.command, message.substring(0, 100)); + perfLogger.logStartCommand("" + request.command, message.toString().substring(0, 100)); const { response, responseRequired } = this.executeCommand(request); if (this.logger.hasLevel(LogLevel.requestTime)) { @@ -2943,7 +2943,7 @@ namespace ts.server { return; } - this.logErrorWorker(err, message, relevantFile); + this.logErrorWorker(err, message.toString(), relevantFile); perfLogger.logStopCommand("" + (request && request.command), "Error: " + err); this.doOutput( @@ -2955,6 +2955,10 @@ namespace ts.server { } } + protected parseMessage(message: MessageType): protocol.Request { + return JSON.parse(message as any as string); + } + private getFormatOptions(file: NormalizedPath): FormatCodeSettings { return this.projectService.getFormatCodeOptions(file); } diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index 3f6ba6b2a1769..d5e4eebddbb7d 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -1,133 +1,48 @@ 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; - } + //#region Platform - 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); - } + const enum Runtime { + Node, + Web + }; - interface NodeChildProcess { - send(message: any, sendHandle?: any): void; - on(message: "message" | "exit", f: (m: any) => void): void; - kill(): void; - pid: number; - } + const runtime = typeof process !== "undefined" ? Runtime.Node : Runtime.Web; - interface ReadLineOptions { - input: NodeJS.ReadableStream; - output?: NodeJS.WritableStream; - terminal?: boolean; - historySize?: number; - } + const platform = function () { + if (runtime === Runtime.Web) { + return "web"; + } + return require("os").platform(); + }; - 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; + //#endregion + + class NoopLogger implements server.Logger { // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier + close(): void { /* noop */ } + hasLevel(_level: LogLevel): boolean { return false; } + loggingEnabled(): boolean { return false; } + perftrc(_s: string): void { /* noop */ } + info(_s: string): void { /* noop */ } + startGroup(): void { /* noop */ } + endGroup(): void { /* noop */ } + msg(_s: string, _type?: Msg | undefined): void { /* noop */ } + getLogFileName(): string | undefined { return undefined; } } - 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 + class NodeLogger 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, + constructor( + private readonly sys: ServerHost, + private readonly logFilename: string, private readonly traceToConsole: boolean, private readonly level: LogLevel) { if (this.logFilename) { try { - this.fd = fs.openSync(this.logFilename, "w"); + this.fd = require("fs").openSync(this.logFilename, "w"); } catch (_) { // swallow the error and keep logging disabled if file cannot be opened @@ -135,13 +50,13 @@ namespace ts.server { } } - static padStringRight(str: string, padding: string) { + private static padStringRight(str: string, padding: string) { return (str + padding).slice(0, padding.length); } close() { if (this.fd >= 0) { - fs.close(this.fd, noop); + require("fs").close(this.fd, noop); } } @@ -195,7 +110,7 @@ namespace ts.server { s = `[${nowString()}] ${s}\n`; if (!this.inGroup || this.firstInGroup) { - const prefix = Logger.padStringRight(type + " " + this.seq.toString(), " "); + const prefix = NodeLogger.padStringRight(type + " " + this.seq.toString(), " "); s = prefix + s; } this.write(s); @@ -210,9 +125,9 @@ namespace ts.server { private write(s: string) { if (this.fd >= 0) { - const buf = sys.bufferFrom!(s); + const buf = this.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 + require("fs").writeSync(this.fd, buf as globalThis.Buffer, 0, buf.length, /*position*/ null!); // TODO: GH#18217 } if (this.traceToConsole) { console.warn(s); @@ -220,370 +135,6 @@ namespace ts.server { } } - 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)); - - 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; - private constructed: boolean | undefined; - - constructor() { - const event: Event | undefined = (body: object, eventName: string) => { - if (this.constructed) { - this.event(body, eventName); - } - else { - // It is unsafe to dereference `this` before initialization completes, - // so we defer until the next tick. - // - // Construction should finish before the next tick fires, so we do not need to do this recursively. - // eslint-disable-next-line no-restricted-globals - setImmediate(() => 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(); - 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; @@ -651,7 +202,10 @@ namespace ts.server { } // TSS_LOG "{ level: "normal | verbose | terse", file?: string}" - function createLogger() { + function createLogger(): Logger { + if (runtime === Runtime.Web) { + return new NoopLogger(); + } const cmdLineLogFileName = findArgument("--logFile"); const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); const envLogOptions = parseLoggingEnvironmentString(process.env.TSS_LOG); @@ -667,7 +221,7 @@ namespace ts.server { : undefined; const logVerbosity = cmdLineVerbosity || envLogOptions.detailLevel; - return new Logger(substitutedLogFileName!, envLogOptions.traceToConsole!, logVerbosity!); // TODO: GH#18217 + return new NodeLogger(sys, substitutedLogFileName!, envLogOptions.traceToConsole!, logVerbosity!); // TODO: GH#18217 } // This places log file in the directory containing editorServices.js // TODO: check that this location is writable @@ -680,7 +234,7 @@ namespace ts.server { return { getModifiedTime, poll, startWatchTimer, addFile, removeFile }; function getModifiedTime(fileName: string): Date { - return fs.statSync(fileName).mtime; + return require("fs").statSync(fileName).mtime; } function poll(checkedIndex: number) { @@ -689,7 +243,7 @@ namespace ts.server { return; } - fs.stat(watchedFile.fileName, (err, stats) => { + require("fs").stat(watchedFile.fileName, (err: any, stats: any) => { if (err) { if (err.code === "ENOENT") { if (watchedFile.mtime.getTime() !== 0) { @@ -767,26 +321,6 @@ namespace ts.server { // time dynamically to match the large reference set? const pollingWatchedFileSet = createPollingWatchedFileSet(); - const pending: Buffer[] = []; - let canWrite = true; - - 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)) { @@ -819,115 +353,168 @@ namespace ts.server { const logger = createLogger(); - 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); + function createNodeSys(): ServerHost { + class NodeWriter { + private readonly pending: Buffer[] = []; + private canWrite = true; + + public writeMessage(buf: Buffer) { + if (!this.canWrite) { + this.pending.push(buf); + } + else { + this.canWrite = false; + process.stdout.write(buf, this.setCanWriteFlagAndWriteMessageIfNecessary.bind(this)); + } + } + + private setCanWriteFlagAndWriteMessageIfNecessary() { + this.canWrite = true; + if (this.pending.length) { + this.writeMessage(this.pending.shift()!); + } + } } - catch (e) { - logger.info(`Exception when creating directory watcher: ${e.message}`); - return noopWatcher; + + const sys = ts.sys; + + // use watchGuard process on Windows when node version is 4 or later + const useWatchGuard = process.platform === "win32" && getNodeMajorVersion()! >= 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; + } } - } - 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 (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(`Starting ${process.execPath} with args:${stringifyIndented(args)}`); + logger.info(`${cacheKey} for path ${path} not found in cache...`); } - 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`); + try { + const args = [combinePaths(__dirname, "watchGuard.js"), path]; + if (logger.hasLevel(LogLevel.verbose)) { + logger.info(`Starting ${process.execPath} with args:${stringifyIndented(args)}`); + } + require("child_process").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}`); + catch (e) { + status = false; + if (logger.hasLevel(LogLevel.verbose)) { + logger.info(`WatchGuard for path ${path} returned: ${e.message}`); + } + } + if (cacheKey) { + statusCache.set(cacheKey, status); } } - 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; + } + }; + } + else { + sys.watchDirectory = watchDirectorySwallowingException; + } + + // Override sys.write because fs.writeSync is not reliable on Node 4 + const writer = new NodeWriter(); + sys.write = (s: string) => writer.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 }; } - 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; + catch (error) { + return { module: undefined, error }; } }; - } - else { - sys.watchDirectory = watchDirectorySwallowingException; + + return sys; } - // 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) - }; - }; + function createWebSys(): ServerHost { + const sys = { + getExecutingFilePath: () => "", // TODO: + getCurrentDirectory: () => "", //TODO + createHash: (data: string) => data, + } as ServerHost; + + sys.args = []; // TODO + + sys.write = (s: string) => postMessage(s); - /* eslint-disable no-restricted-globals */ - sys.setTimeout = setTimeout; - sys.clearTimeout = clearTimeout; - sys.setImmediate = setImmediate; - sys.clearImmediate = clearImmediate; - /* eslint-enable no-restricted-globals */ + /* eslint-disable no-restricted-globals */ + sys.setTimeout = (callback: any, time: number, ...args: any[]) => setTimeout(callback, time, ...args); + sys.clearTimeout = (timeout: any) => clearTimeout(timeout); + sys.setImmediate = (x: any) => setTimeout(x, 0); + sys.clearImmediate = (x: any) => clearTimeout(x); + /* eslint-enable no-restricted-globals */ - if (typeof global !== "undefined" && global.gc) { - sys.gc = () => global.gc(); + sys.require = (_initialDir: string, _moduleName: string): RequireResult => { + return { module: undefined, error: new Error("Not implemented") }; + }; + + return sys; } - sys.require = (initialDir: string, moduleName: string): RequireResult => { + const sys = runtime === Runtime.Node ? createNodeSys() : createWebSys(); + ts.sys = sys; + + let cancellationToken: ServerCancellationToken = nullCancellationToken; + if (runtime === Runtime.Node) { try { - return { module: require(resolveJSModule(moduleName, initialDir, sys)), error: undefined }; + const factory = require("./cancellationToken"); + cancellationToken = factory(sys.args); } - catch (error) { - return { module: undefined, error }; + catch (e) { + // noop } - }; - - 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) { @@ -936,11 +523,6 @@ namespace ts.server { 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) { @@ -949,6 +531,574 @@ namespace ts.server { return arg.split(",").filter(name => name !== ""); } + interface LaunchOptions { + readonly useSingleInferredProject: boolean; + readonly useInferredProjectPerProjectRoot: boolean; + readonly suppressDiagnosticEvents?: boolean; + readonly syntaxOnly?: boolean; + readonly serverMode?: LanguageServiceMode; + readonly telemetryEnabled: boolean; + readonly noGetErrOnBackgroundUpdate?: boolean; + } + + function startNodeServer(options: LaunchOptions) { + + interface NodeSocket { + write(data: string, encoding: string): boolean; + } + + interface NodeChildProcess { + send(message: any, sendHandle?: any): void; + on(message: "message" | "exit", f: (m: any) => void): void; + kill(): void; + pid: number; + } + + interface QueuedOperation { + operationId: string; + operation: () => void; + } + + class NodeTypingsInstaller implements ITypingsInstaller { + + public static getGlobalTypingsCacheLocation() { + const os = require("os"); + 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 = NodeTypingsInstaller.getNonWindowsCacheLocation(process.platform === "darwin"); + return combinePaths(combinePaths(cacheLocation, "typescript"), versionMajorMinor); + } + default: + return Debug.fail(`unsupported platform '${process.platform}'`); + } + } + + private static getNonWindowsCacheLocation(platformIsDarwin: boolean) { + if (process.env.XDG_CACHE_HOME) { + return process.env.XDG_CACHE_HOME; + } + const os = require("os"); + 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); + } + + 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 = require("child_process").fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv }); + this.installer.on("message", m => this.handleMessage(m)); + + 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; + private constructed: boolean | undefined; + private readonly rl: import("readline").Interface; + + constructor(sys: ServerHost, config: LaunchOptions & { + globalPlugins: readonly string[], + pluginProbeLocations: readonly string[], + allowLocalPluginLoads: boolean, + disableAutomaticTypingAcquisition: boolean, + typingSafeListLocation: string, + typesMapLocation: string, + npmLocation: string | undefined, + validateDefaultNpmLocation: boolean, + }) { + const event: Event | undefined = (body: object, eventName: string) => { + if (this.constructed) { + this.event(body, eventName); + } + else { + // It is unsafe to dereference `this` before initialization completes, + // so we defer until the next tick. + // + // Construction should finish before the next tick fires, so we do not need to do this recursively. + // eslint-disable-next-line no-restricted-globals + setImmediate(() => this.event(body, eventName)); + } + }; + + const host = sys; + + const typingsInstaller = config.disableAutomaticTypingAcquisition + ? nullTypingsInstaller + : new NodeTypingsInstaller(config.telemetryEnabled, logger, host, NodeTypingsInstaller.getGlobalTypingsCacheLocation(), config.typingSafeListLocation, config.typesMapLocation, config.npmLocation, config.validateDefaultNpmLocation, event); + + super({ + host, + cancellationToken, + useSingleInferredProject: config.useSingleInferredProject, + useInferredProjectPerProjectRoot: config.useInferredProjectPerProjectRoot, + typingsInstaller, + byteLength: Buffer.byteLength, + hrtime: process.hrtime, + logger, + canUseEvents: true, + suppressDiagnosticEvents: config.suppressDiagnosticEvents, + syntaxOnly: config.syntaxOnly, + serverMode: config.serverMode, + noGetErrOnBackgroundUpdate: config.noGetErrOnBackgroundUpdate, + globalPlugins: config.globalPlugins, + pluginProbeLocations: config.pluginProbeLocations, + allowLocalPluginLoads: config.allowLocalPluginLoads, + typesMapLocation: config.typesMapLocation, + }); + + this.eventPort = eventPort; + if (this.canUseEvents && this.eventPort) { + const s = require("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; + + this.rl = require("readline").createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, + }); + } + + 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(); + process.exit(0); + } + + listen() { + this.rl.on("line", (input: string) => { + const message = input.trim(); + this.onMessage(message); + }); + + this.rl.on("close", () => { + this.exit(); + }); + } + } + + const globalPlugins = parseStringArray("--globalPlugins"); + const pluginProbeLocations = parseStringArray("--pluginProbeLocations"); + const allowLocalPluginLoads = hasArgument("--allowLocalPluginLoads"); + + const disableAutomaticTypingAcquisition = hasArgument("--disableAutomaticTypingAcquisition"); + 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 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 session = new IOSession(sys, { + ...options, + globalPlugins, + pluginProbeLocations, + allowLocalPluginLoads, + disableAutomaticTypingAcquisition, + typingSafeListLocation, + typesMapLocation, + npmLocation, + validateDefaultNpmLocation, + }); + + process.on("uncaughtException", err => { + session.logError(err, "unknown"); + }); + // See https://github.com/Microsoft/TypeScript/issues/11348 + (process as any).noAsar = true; + + // Start listening + session.listen(); + + if (sys.tryEnableSourceMapsForHost && /^development$/i.test(sys.getEnvironmentVariable("NODE_ENV"))) { + 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); + } + + declare const addEventListener: any; + declare const removeEventListener: any; + declare const postMessage: any; + declare const close: any; + + function startWebServer(launchOptions: LaunchOptions) { + class WorkerSession extends Session { + constructor() { + const host = sys; + + super({ + host, + cancellationToken, + useSingleInferredProject: launchOptions.useSingleInferredProject, + useInferredProjectPerProjectRoot: launchOptions.useInferredProjectPerProjectRoot, + typingsInstaller: nullTypingsInstaller, + byteLength: () => 1, // TODO! + // From https://github.com/kumavis/browser-process-hrtime/blob/master/index.js + hrtime: (previousTimestamp) => { + const clocktime = (performance as any).now.call(performance) * 1e-3; + let seconds = Math.floor(clocktime); + let nanoseconds = Math.floor((clocktime % 1) * 1e9); + if (previousTimestamp) { + seconds = seconds - previousTimestamp[0]; + nanoseconds = nanoseconds - previousTimestamp[1]; + if (nanoseconds < 0) { + seconds--; + nanoseconds += 1e9; + } + } + return [seconds, nanoseconds]; + }, + logger, + canUseEvents: false, + suppressDiagnosticEvents: launchOptions.suppressDiagnosticEvents, + syntaxOnly: launchOptions.syntaxOnly, + noGetErrOnBackgroundUpdate: launchOptions.noGetErrOnBackgroundUpdate, + globalPlugins: [], + pluginProbeLocations: [], + allowLocalPluginLoads: false, + typesMapLocation: undefined, + }); + } + + public send(msg: protocol.Message) { + postMessage(msg); + } + + protected parseMessage(message: any): protocol.Request { + return message; + } + + 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(); + } + let unknownServerMode: string | undefined; function parseServerMode(): LanguageServiceMode | undefined { const mode = findArgument("--serverMode"); @@ -969,47 +1119,51 @@ namespace ts.server { } } - 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"); - - 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(); + function start(args: string[]) { + const serverMode = parseServerMode(); + const syntaxOnly = hasArgument("--syntaxOnly") || runtime !== Runtime.Node; + + const options: LaunchOptions = { + useSingleInferredProject: hasArgument("--useSingleInferredProject"), + useInferredProjectPerProjectRoot: hasArgument("--useInferredProjectPerProjectRoot"), + suppressDiagnosticEvents: hasArgument("--suppressDiagnosticEvents"), + syntaxOnly, + serverMode, + telemetryEnabled: hasArgument(Arguments.EnableTelemetry), + noGetErrOnBackgroundUpdate: hasArgument("--noGetErrOnBackgroundUpdate"), + }; + + logger.info(`Starting TS Server`); + logger.info(`Version: ${version}`); + logger.info(`Arguments: ${runtime === Runtime.Node ? args.join(" ") : []}`); + logger.info(`Platform: ${platform()} NodeVersion: ${getNodeMajorVersion()} CaseSensitive: ${sys.useCaseSensitiveFileNames}`); + logger.info(`ServerMode: ${serverMode} syntaxOnly: ${syntaxOnly} hasUnknownServerMode: ${unknownServerMode}`); + + if (runtime === Runtime.Node) { + startNodeServer(options); + } + else { + startWebServer(options); + } + + if (Debug.isDebugging) { + Debug.enableDebugInfo(); + } } - if (ts.sys.tryEnableSourceMapsForHost && /^development$/i.test(ts.sys.getEnvironmentVariable("NODE_ENV"))) { - ts.sys.tryEnableSourceMapsForHost(); + if (runtime === Runtime.Node) { + start(process.argv); } + else { + // Get args from first message + const listener = (e: any) => { + removeEventListener("message", listener); - // 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 args = e.data; + sys.args = args; + start(args); + }; + + addEventListener("message", listener); + } } From 30cb1a6ccac434404eb2eadfb3a2c01dc8a81289 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 22 Jul 2020 17:53:15 -0700 Subject: [PATCH 02/26] Shim out directoryExists --- src/tsserver/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index d5e4eebddbb7d..f8a6cd3ab78bf 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -482,6 +482,7 @@ namespace ts.server { getExecutingFilePath: () => "", // TODO: getCurrentDirectory: () => "", //TODO createHash: (data: string) => data, + directoryExists: (_path) => false, // TODO } as ServerHost; sys.args = []; // TODO From fb900a0ee072decf33a1bbf37fc3c4e857baef8e Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 31 Aug 2020 16:07:02 -0700 Subject: [PATCH 03/26] Add some regions --- src/tsserver/server.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index f8a6cd3ab78bf..724a9be8848a2 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -17,6 +17,8 @@ namespace ts.server { //#endregion + //#region Logging + class NoopLogger implements server.Logger { // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier close(): void { /* noop */ } hasLevel(_level: LogLevel): boolean { return false; } @@ -226,6 +228,10 @@ namespace ts.server { // This places log file in the directory containing editorServices.js // TODO: check that this location is writable + //#endregion + + //#region File watching + // average async stat takes about 30 microseconds // set chunk size to do 30 files in < 1 millisecond function createPollingWatchedFileSet(interval = 2500, chunkSize = 30) { @@ -347,12 +353,16 @@ namespace ts.server { return undefined; } + //#endregion + function isUNCPath(s: string): boolean { return s.length > 2 && s.charCodeAt(0) === CharacterCodes.slash && s.charCodeAt(1) === CharacterCodes.slash; } const logger = createLogger(); + //#region Sys + function createNodeSys(): ServerHost { class NodeWriter { private readonly pending: Buffer[] = []; @@ -506,6 +516,8 @@ namespace ts.server { const sys = runtime === Runtime.Node ? createNodeSys() : createWebSys(); ts.sys = sys; + //#endregion + let cancellationToken: ServerCancellationToken = nullCancellationToken; if (runtime === Runtime.Node) { try { From 4ce8d4a05758772d32ea40185407a2d9201701ad Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 31 Aug 2020 16:10:29 -0700 Subject: [PATCH 04/26] Remove some inlined note types Use import types instead --- src/tsserver/server.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index 724a9be8848a2..862777549176c 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -556,22 +556,13 @@ namespace ts.server { function startNodeServer(options: LaunchOptions) { - interface NodeSocket { - write(data: string, encoding: string): boolean; - } - - interface NodeChildProcess { - send(message: any, sendHandle?: any): void; - on(message: "message" | "exit", f: (m: any) => void): void; - kill(): void; - pid: number; - } - interface QueuedOperation { operationId: string; operation: () => void; } + type ResponseType = TypesRegistryResponse | PackageInstalledResponse | SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse; + class NodeTypingsInstaller implements ITypingsInstaller { public static getGlobalTypingsCacheLocation() { @@ -616,7 +607,7 @@ namespace ts.server { return combinePaths(normalizeSlashes(homePath), cacheFolder); } - private installer!: NodeChildProcess; + private installer!: import("child_process").ChildProcess; private projectService!: ProjectService; private activeRequestCount = 0; private requestQueue: QueuedOperation[] = []; @@ -711,7 +702,7 @@ namespace ts.server { } this.installer = require("child_process").fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv }); - this.installer.on("message", m => this.handleMessage(m)); + this.installer.on("message", m => this.handleMessage(m as ResponseType)); this.event({ pid: this.installer.pid }, "typingsInstallerPid"); @@ -757,7 +748,7 @@ namespace ts.server { } } - private handleMessage(response: TypesRegistryResponse | PackageInstalledResponse | SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse) { + private handleMessage(response: ResponseType) { if (this.logger.hasLevel(LogLevel.verbose)) { this.logger.info(`Received response:${stringifyIndented(response)}`); } @@ -869,7 +860,7 @@ namespace ts.server { class IOSession extends Session { private eventPort: number | undefined; - private eventSocket: NodeSocket | undefined; + private eventSocket: import("net").Socket | undefined; private socketEventQueue: { body: any, eventName: string }[] | undefined; private constructed: boolean | undefined; private readonly rl: import("readline").Interface; From 94faf66b6fa5fe9a536f050c51b3e5576edbff74 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 31 Aug 2020 16:13:55 -0700 Subject: [PATCH 05/26] Use switch case for runtime --- src/tsserver/server.ts | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index 862777549176c..fdd557c131619 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -1123,7 +1123,7 @@ namespace ts.server { } } - function start(args: string[]) { + function start(args: readonly string[]) { const serverMode = parseServerMode(); const syntaxOnly = hasArgument("--syntaxOnly") || runtime !== Runtime.Node; @@ -1155,19 +1155,29 @@ namespace ts.server { } } - if (runtime === Runtime.Node) { - start(process.argv); - } - else { - // Get args from first message - const listener = (e: any) => { - removeEventListener("message", listener); - - const args = e.data; - sys.args = args; - start(args); - }; + switch (runtime) { + case Runtime.Node: + { + start(process.argv); + break; + } + case Runtime.Web: + { + // Get args from first message + const listener = (e: any) => { + removeEventListener("message", listener); + + const args = e.data; + sys.args = args; + start(args); + }; - addEventListener("message", listener); + addEventListener("message", listener); + break; + } + default: + { + throw new Error("Unknown runtime"); + } } } From f3178b6e94d0be01dccb0ab89c07e1edb47678be Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 22 Sep 2020 19:04:48 -0700 Subject: [PATCH 06/26] Review and updates --- src/compiler/builder.ts | 2 +- src/compiler/builderState.ts | 8 +- src/compiler/program.ts | 5 +- src/compiler/watch.ts | 4 +- src/server/editorServices.ts | 1 - src/server/project.ts | 7 +- src/server/session.ts | 12 +- src/tsserver/nodeServer.ts | 978 +++++++++++++ src/tsserver/server.ts | 1211 +---------------- src/tsserver/tsconfig.json | 2 + src/tsserver/webServer.ts | 148 ++ .../reference/api/tsserverlibrary.d.ts | 6 +- 12 files changed, 1219 insertions(+), 1165 deletions(-) create mode 100644 src/tsserver/nodeServer.ts create mode 100644 src/tsserver/webServer.ts diff --git a/src/compiler/builder.ts b/src/compiler/builder.ts index 4c2f6258ddfea..b5803932c4e5f 100644 --- a/src/compiler/builder.ts +++ b/src/compiler/builder.ts @@ -900,7 +900,7 @@ namespace ts { /** * Computing hash to for signature verification */ - const computeHash = host.createHash || generateDjb2Hash; + const computeHash = maybeBind(host, host.createHash); let state = createBuilderProgramState(newProgram, getCanonicalFileName, oldState); let backupState: BuilderProgramState | undefined; newProgram.getProgramBuildInfo = () => getProgramBuildInfo(state, getCanonicalFileName); diff --git a/src/compiler/builderState.ts b/src/compiler/builderState.ts index 1c26513897aee..5a80adad6e71b 100644 --- a/src/compiler/builderState.ts +++ b/src/compiler/builderState.ts @@ -77,7 +77,7 @@ namespace ts { /** * Compute the hash to store the shape of the file */ - export type ComputeHash = (data: string) => string; + export type ComputeHash = ((data: string) => string) | undefined; /** * Exported modules to from declaration emit being computed. @@ -337,7 +337,7 @@ namespace ts { emitOutput.outputFiles.length > 0 ? emitOutput.outputFiles[0] : undefined; if (firstDts) { Debug.assert(fileExtensionIs(firstDts.name, Extension.Dts), "File extension for signature expected to be dts", () => `Found: ${getAnyExtensionFromPath(firstDts.name)} for ${firstDts.name}:: All output files: ${JSON.stringify(emitOutput.outputFiles.map(f => f.name))}`); - latestSignature = computeHash(firstDts.text); + latestSignature = (computeHash || generateDjb2Hash)(firstDts.text); if (exportedModulesMapCache && latestSignature !== prevSignature) { updateExportedModules(sourceFile, emitOutput.exportedModulesFromDeclarationEmit, exportedModulesMapCache); } @@ -521,7 +521,7 @@ namespace ts { /** * When program emits modular code, gets the files affected by the sourceFile whose shape has changed */ - function getFilesAffectedByUpdatedShapeWhenModuleEmit(state: BuilderState, programOfThisState: Program, sourceFileWithUpdatedShape: SourceFile, cacheToUpdateSignature: ESMap, cancellationToken: CancellationToken | undefined, computeHash: ComputeHash | undefined, exportedModulesMapCache: ComputingExportedModulesMap | undefined) { + function getFilesAffectedByUpdatedShapeWhenModuleEmit(state: BuilderState, programOfThisState: Program, sourceFileWithUpdatedShape: SourceFile, cacheToUpdateSignature: ESMap, cancellationToken: CancellationToken | undefined, computeHash: ComputeHash, exportedModulesMapCache: ComputingExportedModulesMap | undefined) { if (isFileAffectingGlobalScope(sourceFileWithUpdatedShape)) { return getAllFilesExcludingDefaultLibraryFile(state, programOfThisState, sourceFileWithUpdatedShape); } @@ -544,7 +544,7 @@ namespace ts { if (!seenFileNamesMap.has(currentPath)) { const currentSourceFile = programOfThisState.getSourceFileByPath(currentPath)!; seenFileNamesMap.set(currentPath, currentSourceFile); - if (currentSourceFile && updateShapeSignature(state, programOfThisState, currentSourceFile, cacheToUpdateSignature, cancellationToken, computeHash!, exportedModulesMapCache)) { // TODO: GH#18217 + if (currentSourceFile && updateShapeSignature(state, programOfThisState, currentSourceFile, cacheToUpdateSignature, cancellationToken, computeHash, exportedModulesMapCache)) { // TODO: GH#18217 queue.push(...getReferencedByPaths(state, currentSourceFile.resolvedPath)); } } diff --git a/src/compiler/program.ts b/src/compiler/program.ts index 76fa4b62aab9c..70ea81c35d52a 100644 --- a/src/compiler/program.ts +++ b/src/compiler/program.ts @@ -73,6 +73,7 @@ namespace ts { export function createCompilerHostWorker(options: CompilerOptions, setParentNodes?: boolean, system = sys): CompilerHost { const existingDirectories = new Map(); const getCanonicalFileName = createGetCanonicalFileName(system.useCaseSensitiveFileNames); + const computeHash = maybeBind(system, system.createHash) || generateDjb2Hash; function getSourceFile(fileName: string, languageVersion: ScriptTarget, onError?: (message: string) => void): SourceFile | undefined { let text: string | undefined; try { @@ -128,7 +129,7 @@ namespace ts { let outputFingerprints: ESMap; function writeFileWorker(fileName: string, data: string, writeByteOrderMark: boolean) { - if (!isWatchSet(options) || !system.createHash || !system.getModifiedTime) { + if (!isWatchSet(options) || !system.getModifiedTime) { system.writeFile(fileName, data, writeByteOrderMark); return; } @@ -137,7 +138,7 @@ namespace ts { outputFingerprints = new Map(); } - const hash = system.createHash(data); + const hash = computeHash(data); const mtimeBefore = system.getModifiedTime(fileName); if (mtimeBefore) { diff --git a/src/compiler/watch.ts b/src/compiler/watch.ts index 724d57f48b29d..422dc13ab22b4 100644 --- a/src/compiler/watch.ts +++ b/src/compiler/watch.ts @@ -345,11 +345,11 @@ namespace ts { export function setGetSourceFileAsHashVersioned(compilerHost: CompilerHost, host: { createHash?(data: string): string; }) { const originalGetSourceFile = compilerHost.getSourceFile; - const computeHash = host.createHash || generateDjb2Hash; + const computeHash = maybeBind(host, host.createHash) || generateDjb2Hash; compilerHost.getSourceFile = (...args) => { const result = originalGetSourceFile.call(compilerHost, ...args); if (result) { - result.version = computeHash.call(host, result.text); + result.version = computeHash(result.text); } return result; }; diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index d0b4891c96324..9c8c198e1b245 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -730,7 +730,6 @@ namespace ts.server { this.syntaxOnly = false; } - Debug.assert(!!this.host.createHash, "'ServerHost.createHash' is required for ProjectService"); if (this.host.realpath) { this.realpathToScriptInfos = createMultiMap(); } diff --git a/src/server/project.ts b/src/server/project.ts index 510f1291d4375..cac9eefb2913e 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -631,7 +631,7 @@ namespace ts.server { } updateProjectIfDirty(this); this.builderState = BuilderState.create(this.program!, this.projectService.toCanonicalFileName, this.builderState); - return mapDefined(BuilderState.getFilesAffectedBy(this.builderState, this.program!, scriptInfo.path, this.cancellationToken, data => this.projectService.host.createHash!(data)), // TODO: GH#18217 + return mapDefined(BuilderState.getFilesAffectedBy(this.builderState, this.program!, scriptInfo.path, this.cancellationToken, maybeBind(this.projectService.host, this.projectService.host.createHash)), sourceFile => this.shouldEmitFile(this.projectService.getScriptInfoForPath(sourceFile.path)) ? sourceFile.fileName : undefined); } @@ -654,7 +654,10 @@ namespace ts.server { const dtsFiles = outputFiles.filter(f => fileExtensionIs(f.name, Extension.Dts)); if (dtsFiles.length === 1) { const sourceFile = this.program!.getSourceFile(scriptInfo.fileName)!; - BuilderState.updateSignatureOfFile(this.builderState, this.projectService.host.createHash!(dtsFiles[0].text), sourceFile.resolvedPath); + const signature = this.projectService.host.createHash ? + this.projectService.host.createHash(dtsFiles[0].text) : + generateDjb2Hash(dtsFiles[0].text); + BuilderState.updateSignatureOfFile(this.builderState, signature, sourceFile.resolvedPath); } } } diff --git a/src/server/session.ts b/src/server/session.ts index 58d6f5220926d..af61c55e6da44 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -679,7 +679,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; @@ -2905,7 +2905,7 @@ namespace ts.server { if (this.logger.hasLevel(LogLevel.requestTime)) { start = this.hrtime(); if (this.logger.hasLevel(LogLevel.verbose)) { - this.logger.info(`request:${indent(message.toString())}`); + this.logger.info(`request:${indent(this.toStringMessage(message))}`); } } @@ -2915,7 +2915,7 @@ namespace ts.server { request = this.parseMessage(message); relevantFile = request.arguments && (request as protocol.FileRequest).arguments.file ? (request as protocol.FileRequest).arguments : undefined; - perfLogger.logStartCommand("" + request.command, message.toString().substring(0, 100)); + perfLogger.logStartCommand("" + request.command, this.toStringMessage(message).substring(0, 100)); const { response, responseRequired } = this.executeCommand(request); if (this.logger.hasLevel(LogLevel.requestTime)) { @@ -2945,7 +2945,7 @@ namespace ts.server { return; } - this.logErrorWorker(err, message.toString(), relevantFile); + this.logErrorWorker(err, this.toStringMessage(message), relevantFile); perfLogger.logStopCommand("" + (request && request.command), "Error: " + err); this.doOutput( @@ -2961,6 +2961,10 @@ namespace ts.server { return JSON.parse(message as any as string); } + protected toStringMessage(message: MessageType): string { + return message as any as string; + } + private getFormatOptions(file: NormalizedPath): FormatCodeSettings { return this.projectService.getFormatCodeOptions(file); } diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts new file mode 100644 index 0000000000000..da424cb073ab2 --- /dev/null +++ b/src/tsserver/nodeServer.ts @@ -0,0 +1,978 @@ +/*@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 getLogLevel(level: string | undefined) { + if (level) { + const l = level.toLowerCase(); + for (const name in LogLevel) { + if (isNaN(+name) && l === name.toLowerCase()) { + return LogLevel[name]; + } + } + } + return undefined; + } + + 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; + } + } + + 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 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); + } + } + } + + 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 serverMode = parseServerMode(); + 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 { + 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)); + + 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; + private constructed: boolean | undefined; + + constructor() { + const event: Event | undefined = (body: object, eventName: string) => { + if (this.constructed) { + this.event(body, eventName); + } + else { + // It is unsafe to dereference `this` before initialization completes, + // so we defer until the next tick. + // + // Construction should finish before the next tick fires, so we do not need to do this recursively. + // eslint-disable-next-line no-restricted-globals + setImmediate(() => 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(); + 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 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 fdd557c131619..23d13aea7d274 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -1,541 +1,32 @@ +/*@internal*/ namespace ts.server { - //#region Platform - - const enum Runtime { + declare const addEventListener: any; + declare const removeEventListener: any; + const enum SystemKind { Node, Web }; - const runtime = typeof process !== "undefined" ? Runtime.Node : Runtime.Web; - - const platform = function () { - if (runtime === Runtime.Web) { - return "web"; - } - return require("os").platform(); - }; - - //#endregion - - //#region Logging - - class NoopLogger implements server.Logger { // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier - close(): void { /* noop */ } - hasLevel(_level: LogLevel): boolean { return false; } - loggingEnabled(): boolean { return false; } - perftrc(_s: string): void { /* noop */ } - info(_s: string): void { /* noop */ } - startGroup(): void { /* noop */ } - endGroup(): void { /* noop */ } - msg(_s: string, _type?: Msg | undefined): void { /* noop */ } - getLogFileName(): string | undefined { return undefined; } - } - - class NodeLogger 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 sys: ServerHost, - private readonly logFilename: string, - private readonly traceToConsole: boolean, - private readonly level: LogLevel) { - if (this.logFilename) { - try { - this.fd = require("fs").openSync(this.logFilename, "w"); - } - catch (_) { - // swallow the error and keep logging disabled if file cannot be opened - } - } - } - - private static padStringRight(str: string, padding: string) { - return (str + padding).slice(0, padding.length); - } - - close() { - if (this.fd >= 0) { - require("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 = NodeLogger.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 = this.sys.bufferFrom!(s); - // eslint-disable-next-line no-null/no-null - require("fs").writeSync(this.fd, buf as globalThis.Buffer, 0, buf.length, /*position*/ null!); // TODO: GH#18217 - } - if (this.traceToConsole) { - console.warn(s); - } - } - } - - 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 }; - } - } - - function getLogLevel(level: string | undefined) { - if (level) { - const l = level.toLowerCase(); - for (const name in LogLevel) { - if (isNaN(+name) && l === name.toLowerCase()) { - return LogLevel[name]; - } - } - } - return undefined; - } - - // TSS_LOG "{ level: "normal | verbose | terse", file?: string}" - function createLogger(): Logger { - if (runtime === Runtime.Web) { - return new NoopLogger(); - } - 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 NodeLogger(sys, substitutedLogFileName!, envLogOptions.traceToConsole!, logVerbosity!); // TODO: GH#18217 - } - // This places log file in the directory containing editorServices.js - // TODO: check that this location is writable - - //#endregion - - //#region File watching - - // 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 { - return require("fs").statSync(fileName).mtime; - } - - function poll(checkedIndex: number) { - const watchedFile = watchedFiles[checkedIndex]; - if (!watchedFile) { - return; - } - - require("fs").stat(watchedFile.fileName, (err: any, stats: any) => { - 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); - } - } - - // 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(); - - 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; - } - - //#endregion - - function isUNCPath(s: string): boolean { - return s.length > 2 && s.charCodeAt(0) === CharacterCodes.slash && s.charCodeAt(1) === CharacterCodes.slash; - } - - const logger = createLogger(); - - //#region Sys - - function createNodeSys(): ServerHost { - class NodeWriter { - private readonly pending: Buffer[] = []; - private canWrite = true; - - public writeMessage(buf: Buffer) { - if (!this.canWrite) { - this.pending.push(buf); - } - else { - this.canWrite = false; - process.stdout.write(buf, this.setCanWriteFlagAndWriteMessageIfNecessary.bind(this)); - } - } - - private setCanWriteFlagAndWriteMessageIfNecessary() { - this.canWrite = true; - if (this.pending.length) { - this.writeMessage(this.pending.shift()!); - } - } - } - - const sys = ts.sys; - - // use watchGuard process on Windows when node version is 4 or later - const useWatchGuard = process.platform === "win32" && getNodeMajorVersion()! >= 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; - } - } - - 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)}`); - } - require("child_process").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; - } - }; - } - else { - sys.watchDirectory = watchDirectorySwallowingException; - } - - // Override sys.write because fs.writeSync is not reliable on Node 4 - const writer = new NodeWriter(); - sys.write = (s: string) => writer.writeMessage(sys.bufferFrom!(s, "utf8") as globalThis.Buffer); - - sys.watchFile = (fileName, callback) => { - const watchedFile = pollingWatchedFileSet.addFile(fileName, callback); - return { - close: () => pollingWatchedFileSet.removeFile(watchedFile) + const systemKind = typeof process !== "undefined" ? SystemKind.Node : SystemKind.Web; + const platform = () => systemKind === SystemKind.Web ? "web" : require("os").platform(); + setStackTraceLimit(); + switch (systemKind) { + case SystemKind.Node: + start(initializeNodeSystem()); + break; + case SystemKind.Web: + // Get args from first message + const listener = (e: any) => { + removeEventListener("message", listener); + const args = e.data; + start(initializeWebSystem(args)); }; - }; - - /* 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 }; - } - }; - - return sys; - } - - function createWebSys(): ServerHost { - const sys = { - getExecutingFilePath: () => "", // TODO: - getCurrentDirectory: () => "", //TODO - createHash: (data: string) => data, - directoryExists: (_path) => false, // TODO - } as ServerHost; - - sys.args = []; // TODO - - sys.write = (s: string) => postMessage(s); - - /* eslint-disable no-restricted-globals */ - sys.setTimeout = (callback: any, time: number, ...args: any[]) => setTimeout(callback, time, ...args); - sys.clearTimeout = (timeout: any) => clearTimeout(timeout); - sys.setImmediate = (x: any) => setTimeout(x, 0); - sys.clearImmediate = (x: any) => clearTimeout(x); - /* eslint-enable no-restricted-globals */ - - sys.require = (_initialDir: string, _moduleName: string): RequireResult => { - return { module: undefined, error: new Error("Not implemented") }; - }; - - return sys; - } - - const sys = runtime === Runtime.Node ? createNodeSys() : createWebSys(); - ts.sys = sys; - - //#endregion - - let cancellationToken: ServerCancellationToken = nullCancellationToken; - if (runtime === Runtime.Node) { - try { - const factory = require("./cancellationToken"); - cancellationToken = factory(sys.args); - } - catch (e) { - // noop - } - } - - const localeStr = findArgument("--locale"); - if (localeStr) { - validateLocaleAndSetLanguage(localeStr, sys); + addEventListener("message", listener); + break; + default: + Debug.assertNever(systemKind, "Unknown system kind"); } - setStackTraceLimit(); - function parseStringArray(argName: string): readonly string[] { const arg = findArgument(argName); if (arg === undefined) { @@ -544,481 +35,40 @@ namespace ts.server { return arg.split(",").filter(name => name !== ""); } - interface LaunchOptions { - readonly useSingleInferredProject: boolean; - readonly useInferredProjectPerProjectRoot: boolean; - readonly suppressDiagnosticEvents?: boolean; - readonly syntaxOnly?: boolean; - readonly serverMode?: LanguageServiceMode; - readonly telemetryEnabled: boolean; - readonly noGetErrOnBackgroundUpdate?: boolean; - } - - function startNodeServer(options: LaunchOptions) { - - interface QueuedOperation { - operationId: string; - operation: () => void; - } - - type ResponseType = TypesRegistryResponse | PackageInstalledResponse | SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse; - - class NodeTypingsInstaller implements ITypingsInstaller { - - public static getGlobalTypingsCacheLocation() { - const os = require("os"); - 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 = NodeTypingsInstaller.getNonWindowsCacheLocation(process.platform === "darwin"); - return combinePaths(combinePaths(cacheLocation, "typescript"), versionMajorMinor); - } - default: - return Debug.fail(`unsupported platform '${process.platform}'`); - } - } - - private static getNonWindowsCacheLocation(platformIsDarwin: boolean) { - if (process.env.XDG_CACHE_HOME) { - return process.env.XDG_CACHE_HOME; - } - const os = require("os"); - 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); - } - - private installer!: import("child_process").ChildProcess; - 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 = require("child_process").fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv }); - this.installer.on("message", m => this.handleMessage(m as ResponseType)); - - 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: ResponseType) { - 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: import("net").Socket | undefined; - private socketEventQueue: { body: any, eventName: string }[] | undefined; - private constructed: boolean | undefined; - private readonly rl: import("readline").Interface; - - constructor(sys: ServerHost, config: LaunchOptions & { - globalPlugins: readonly string[], - pluginProbeLocations: readonly string[], - allowLocalPluginLoads: boolean, - disableAutomaticTypingAcquisition: boolean, - typingSafeListLocation: string, - typesMapLocation: string, - npmLocation: string | undefined, - validateDefaultNpmLocation: boolean, - }) { - const event: Event | undefined = (body: object, eventName: string) => { - if (this.constructed) { - this.event(body, eventName); - } - else { - // It is unsafe to dereference `this` before initialization completes, - // so we defer until the next tick. - // - // Construction should finish before the next tick fires, so we do not need to do this recursively. - // eslint-disable-next-line no-restricted-globals - setImmediate(() => this.event(body, eventName)); - } - }; - - const host = sys; - - const typingsInstaller = config.disableAutomaticTypingAcquisition - ? nullTypingsInstaller - : new NodeTypingsInstaller(config.telemetryEnabled, logger, host, NodeTypingsInstaller.getGlobalTypingsCacheLocation(), config.typingSafeListLocation, config.typesMapLocation, config.npmLocation, config.validateDefaultNpmLocation, event); - - super({ - host, - cancellationToken, - useSingleInferredProject: config.useSingleInferredProject, - useInferredProjectPerProjectRoot: config.useInferredProjectPerProjectRoot, - typingsInstaller, - byteLength: Buffer.byteLength, - hrtime: process.hrtime, - logger, - canUseEvents: true, - suppressDiagnosticEvents: config.suppressDiagnosticEvents, - syntaxOnly: config.syntaxOnly, - serverMode: config.serverMode, - noGetErrOnBackgroundUpdate: config.noGetErrOnBackgroundUpdate, - globalPlugins: config.globalPlugins, - pluginProbeLocations: config.pluginProbeLocations, - allowLocalPluginLoads: config.allowLocalPluginLoads, - typesMapLocation: config.typesMapLocation, - }); - - this.eventPort = eventPort; - if (this.canUseEvents && this.eventPort) { - const s = require("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; + export interface StartInput { + args: readonly string[]; + logger: Logger; + cancellationToken: ServerCancellationToken; + serverMode: LanguageServiceMode | undefined; + unknownServerMode?: string; + startSession: (option: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) => void; + } + 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"]; + } + function start({ args, logger, cancellationToken, serverMode, unknownServerMode, startSession: startServer }: StartInput) { + const syntaxOnly = hasArgument("--syntaxOnly"); - this.rl = require("readline").createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false, - }); - } - - 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(); - process.exit(0); - } - - listen() { - this.rl.on("line", (input: string) => { - const message = input.trim(); - this.onMessage(message); - }); - - this.rl.on("close", () => { - this.exit(); - }); - } - } - - const globalPlugins = parseStringArray("--globalPlugins"); - const pluginProbeLocations = parseStringArray("--pluginProbeLocations"); - const allowLocalPluginLoads = hasArgument("--allowLocalPluginLoads"); + 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}`); - const disableAutomaticTypingAcquisition = hasArgument("--disableAutomaticTypingAcquisition"); - 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); + setStackTraceLimit(); - function parseEventPort(eventPortStr: string | undefined) { - const eventPort = eventPortStr === undefined ? undefined : parseInt(eventPortStr); - return eventPort !== undefined && !isNaN(eventPort) ? eventPort : undefined; + if (Debug.isDebugging) { + Debug.enableDebugInfo(); } - const eventPort: number | undefined = parseEventPort(findArgument("--eventPort")); - - const session = new IOSession(sys, { - ...options, - globalPlugins, - pluginProbeLocations, - allowLocalPluginLoads, - disableAutomaticTypingAcquisition, - typingSafeListLocation, - typesMapLocation, - npmLocation, - validateDefaultNpmLocation, - }); - - process.on("uncaughtException", err => { - session.logError(err, "unknown"); - }); - // See https://github.com/Microsoft/TypeScript/issues/11348 - (process as any).noAsar = true; - - // Start listening - session.listen(); - if (sys.tryEnableSourceMapsForHost && /^development$/i.test(sys.getEnvironmentVariable("NODE_ENV"))) { sys.tryEnableSourceMapsForHost(); } @@ -1030,154 +80,21 @@ namespace ts.server { 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); - } - - declare const addEventListener: any; - declare const removeEventListener: any; - declare const postMessage: any; - declare const close: any; - - function startWebServer(launchOptions: LaunchOptions) { - class WorkerSession extends Session { - constructor() { - const host = sys; - - super({ - host, - cancellationToken, - useSingleInferredProject: launchOptions.useSingleInferredProject, - useInferredProjectPerProjectRoot: launchOptions.useInferredProjectPerProjectRoot, - typingsInstaller: nullTypingsInstaller, - byteLength: () => 1, // TODO! - // From https://github.com/kumavis/browser-process-hrtime/blob/master/index.js - hrtime: (previousTimestamp) => { - const clocktime = (performance as any).now.call(performance) * 1e-3; - let seconds = Math.floor(clocktime); - let nanoseconds = Math.floor((clocktime % 1) * 1e9); - if (previousTimestamp) { - seconds = seconds - previousTimestamp[0]; - nanoseconds = nanoseconds - previousTimestamp[1]; - if (nanoseconds < 0) { - seconds--; - nanoseconds += 1e9; - } - } - return [seconds, nanoseconds]; - }, - logger, - canUseEvents: false, - suppressDiagnosticEvents: launchOptions.suppressDiagnosticEvents, - syntaxOnly: launchOptions.syntaxOnly, - noGetErrOnBackgroundUpdate: launchOptions.noGetErrOnBackgroundUpdate, - globalPlugins: [], - pluginProbeLocations: [], - allowLocalPluginLoads: false, - typesMapLocation: undefined, - }); - } - - public send(msg: protocol.Message) { - postMessage(msg); - } - - protected parseMessage(message: any): protocol.Request { - return message; - } - - 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(); - } - - 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; - } - } - - function start(args: readonly string[]) { - const serverMode = parseServerMode(); - const syntaxOnly = hasArgument("--syntaxOnly") || runtime !== Runtime.Node; - - const options: LaunchOptions = { - useSingleInferredProject: hasArgument("--useSingleInferredProject"), - useInferredProjectPerProjectRoot: hasArgument("--useInferredProjectPerProjectRoot"), - suppressDiagnosticEvents: hasArgument("--suppressDiagnosticEvents"), - syntaxOnly, - serverMode, - telemetryEnabled: hasArgument(Arguments.EnableTelemetry), - noGetErrOnBackgroundUpdate: hasArgument("--noGetErrOnBackgroundUpdate"), - }; - - logger.info(`Starting TS Server`); - logger.info(`Version: ${version}`); - logger.info(`Arguments: ${runtime === Runtime.Node ? args.join(" ") : []}`); - logger.info(`Platform: ${platform()} NodeVersion: ${getNodeMajorVersion()} CaseSensitive: ${sys.useCaseSensitiveFileNames}`); - logger.info(`ServerMode: ${serverMode} syntaxOnly: ${syntaxOnly} hasUnknownServerMode: ${unknownServerMode}`); - - if (runtime === Runtime.Node) { - startNodeServer(options); - } - else { - startWebServer(options); - } - - if (Debug.isDebugging) { - Debug.enableDebugInfo(); - } - } - - switch (runtime) { - case Runtime.Node: - { - start(process.argv); - break; - } - case Runtime.Web: - { - // Get args from first message - const listener = (e: any) => { - removeEventListener("message", listener); - - const args = e.data; - sys.args = args; - start(args); - }; - - addEventListener("message", listener); - break; - } - default: + startServer( { - throw new Error("Unknown runtime"); - } + globalPlugins: parseStringArray("--globalPlugins"), + pluginProbeLocations: parseStringArray("--pluginProbeLocations"), + allowLocalPluginLoads: hasArgument("--allowLocalPluginLoads"), + useSingleInferredProject: hasArgument("--useSingleInferredProject"), + useInferredProjectPerProjectRoot: hasArgument("--useInferredProjectPerProjectRoot"), + suppressDiagnosticEvents: hasArgument("--suppressDiagnosticEvents"), + noGetErrOnBackgroundUpdate: hasArgument("--noGetErrOnBackgroundUpdate"), + syntaxOnly, + serverMode + }, + logger, + cancellationToken + ); } } diff --git a/src/tsserver/tsconfig.json b/src/tsserver/tsconfig.json index 52f9fcc4d66f0..0e7a1289dfada 100644 --- a/src/tsserver/tsconfig.json +++ b/src/tsserver/tsconfig.json @@ -8,6 +8,8 @@ ] }, "files": [ + "nodeServer.ts", + "webServer.ts", "server.ts" ], "references": [ diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts new file mode 100644 index 0000000000000..f5b7b22a90e69 --- /dev/null +++ b/src/tsserver/webServer.ts @@ -0,0 +1,148 @@ +/*@internak*/ +namespace ts.server { + declare const addEventListener: any; + declare const postMessage: any; + declare const close: any; + const nullLogger: Logger = { + close: noop, + hasLevel: returnFalse, + loggingEnabled: returnFalse, + perftrc: noop, + info: noop, + msg: noop, + startGroup: noop, + endGroup: noop, + getLogFileName: returnUndefined, + }; + + let unknownServerMode: string | undefined; + function parseServerMode(): LanguageServiceMode | undefined { + const mode = findArgument("--serverMode"); + if (mode !== undefined) { + switch (mode.toLowerCase()) { + case "partialsemantic": + return LanguageServiceMode.PartialSemantic; + case "syntactic": + return LanguageServiceMode.Syntactic; + default: + unknownServerMode = mode; + break; + } + } + // Webserver defaults to partial semantic mode + return hasArgument("--syntaxOnly") ? LanguageServiceMode.Syntactic : LanguageServiceMode.PartialSemantic; + } + + export function initializeWebSystem(args: string[]): StartInput { + createWebSystem(args); + const serverMode = parseServerMode(); + return { + args, + logger: nullLogger, + cancellationToken: nullCancellationToken, + serverMode, + unknownServerMode, + startSession: startWebSession + }; + } + + function createWebSystem(args: string[]) { + Debug.assert(ts.sys === undefined); + const returnEmptyString = () => ""; + const sys: ServerHost = { + args, + newLine: "\r\n", // TODO:: + useCaseSensitiveFileNames: false, // TODO:: + readFile: returnUndefined, // TODO:: read lib Files + + write: postMessage, + watchFile: returnNoopFileWatcher, + watchDirectory: returnNoopFileWatcher, + + getExecutingFilePath: returnEmptyString, // TODO:: Locale, lib locations, typesmap location, plugins + 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; + + // Future for semantic server mode + fileExists: returnFalse, // Module Resolution + 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; + }; + ts.sys = sys; + // TODO:: Locale setting? + } + + function startWebSession(options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) { + class WorkerSession extends Session<{}> { + constructor() { + const host = sys as ServerHost; + + super({ + host, + cancellationToken, + ...options, + typingsInstaller: nullTypingsInstaller, + byteLength: notImplemented, // Formats the message text in send of Session which is override in this class so not needed + hrtime: notImplemented, // Needed for perf logging which is disabled anyway + logger, + canUseEvents: false, + }); + } + + public send(msg: protocol.Message) { + postMessage(msg); + } + + protected parseMessage(message: {}): protocol.Request { + return message; + } + + protected toStringMessage(message: {}) { + return message.toString(); + } + + 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/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 2b5ca8d1f18f6..92862eea032f4 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -9880,7 +9880,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; @@ -10038,7 +10038,9 @@ declare namespace ts.server { private resetCurrentRequest; executeWithRequestId(requestId: number, f: () => T): T; executeCommand(request: protocol.Request): HandlerResponse; - onMessage(message: string): void; + onMessage(message: MessageType): void; + protected parseMessage(message: MessageType): protocol.Request; + protected toStringMessage(message: MessageType): string; private getFormatOptions; private getPreferences; private getHostFormatOptions; From 6202c51856bf39de456ccfcfad6c668f78de5ec1 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Fri, 25 Sep 2020 16:12:13 -0700 Subject: [PATCH 07/26] Enable loading std library d.ts files This implements enough of `ServerHost` that we can load the standard d.ts files using synchronous XMLHttpRequests. I also had to patch some code in `editorServices`. I don't know if these changes are correct and need someone on the TS team to review --- src/server/editorServices.ts | 4 +-- src/tsserver/webServer.ts | 51 ++++++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 9c8c198e1b245..d58ec139af073 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -2489,7 +2489,7 @@ namespace ts.server { } private getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(fileName: NormalizedPath, currentDirectory: string, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined, hostToQueryFileExistsOn: DirectoryStructureHost | undefined) { - if (isRootedDiskPath(fileName) || isDynamicFileName(fileName)) { + if (isRootedDiskPath(fileName) || isDynamicFileName(fileName) || isUrl(fileName)) { return this.getOrCreateScriptInfoWorker(fileName, currentDirectory, /*openedByClient*/ false, /*fileContent*/ undefined, scriptKind, hasMixedContent, hostToQueryFileExistsOn); } @@ -2519,7 +2519,7 @@ namespace ts.server { let info = this.getScriptInfoForPath(path); if (!info) { const isDynamic = isDynamicFileName(fileName); - Debug.assert(isRootedDiskPath(fileName) || isDynamic || openedByClient, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nScript info with non-dynamic relative file name can only be open script info or in context of host currentDirectory`); + Debug.assert(isRootedDiskPath(fileName) || isUrl(fileName) || isDynamic || openedByClient, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nScript info with non-dynamic relative file name can only be open script info or in context of host currentDirectory`); Debug.assert(!isRootedDiskPath(fileName) || this.currentDirectory === currentDirectory || !this.openFilesWithNonRootedDiskPath.has(this.toCanonicalFileName(fileName)), "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nOpen script files with non rooted disk path opened with current directory context cannot have same canonical names`); Debug.assert(!isDynamic || this.currentDirectory === currentDirectory || this.useInferredProjectPerProjectRoot, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nDynamic files must always be opened with service's current directory or service should support inferred project per projectRootPath.`); // If the file is not opened by client and the file doesnot exist on the disk, return diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index f5b7b22a90e69..2fce98bf7bf97 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -3,6 +3,9 @@ namespace ts.server { declare const addEventListener: any; declare const postMessage: any; declare const close: any; + declare const location: any; + declare const XMLHttpRequest: any; + const nullLogger: Logger = { close: noop, hasLevel: returnFalse, @@ -49,17 +52,34 @@ namespace ts.server { function createWebSystem(args: string[]) { Debug.assert(ts.sys === undefined); const returnEmptyString = () => ""; + const sys: ServerHost = { args, - newLine: "\r\n", // TODO:: - useCaseSensitiveFileNames: false, // TODO:: - readFile: returnUndefined, // TODO:: read lib Files + newLine: "\n", // This can be configured by clients + useCaseSensitiveFileNames: false, // Use false as the default on web since that is the safest option + readFile: (path: string, _encoding?: string): string | undefined => { + if (!path.startsWith("http:") && !path.startsWith("https:")) { + return undefined; + } + + const request = new XMLHttpRequest(); + request.open("GET", path, /* asynchronous */ false); + request.send(); + + if (request.status !== 200) { + return undefined; + } + + return request.responseText; + }, write: postMessage, watchFile: returnNoopFileWatcher, watchDirectory: returnNoopFileWatcher, - getExecutingFilePath: returnEmptyString, // TODO:: Locale, lib locations, typesmap location, plugins + getExecutingFilePath: () => { + return location + ""; + }, getCurrentDirectory: returnEmptyString, // For inferred project root if projectRoot path is not set, normalizing the paths /* eslint-disable no-restricted-globals */ @@ -77,11 +97,26 @@ namespace ts.server { // tryEnableSourceMapsForHost?(): void; // debugMode?: boolean; - // Future for semantic server mode - fileExists: returnFalse, // Module Resolution - directoryExists: returnFalse, // Module resolution + // For semantic server mode + fileExists: (path: string): boolean => { + if (!path.startsWith("http:") && !path.startsWith("https:")) { + return false; + } + + const request = new XMLHttpRequest(); + request.open("HEAD", path, /* asynchronous */ false); + request.send(); + + return request.status === 200; + }, + directoryExists: (_path: string): boolean => { + return false; + }, readDirectory: notImplemented, // Configured project, typing installer - getDirectories: () => [], // For automatic type reference directives + getDirectories: (path: string) => { + console.log("paths", path); + return []; + }, // For automatic type reference directives createDirectory: notImplemented, // compile On save writeFile: notImplemented, // compile on save resolvePath: identity, // Plugins From b6e813736d2b7f1c0e7b5c64941faada2eb09792 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Mon, 28 Sep 2020 12:09:59 -0700 Subject: [PATCH 08/26] Update src/tsserver/webServer.ts --- src/tsserver/webServer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 2fce98bf7bf97..a45b8b6261e6c 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -77,9 +77,7 @@ namespace ts.server { watchFile: returnNoopFileWatcher, watchDirectory: returnNoopFileWatcher, - getExecutingFilePath: () => { - return location + ""; - }, + getExecutingFilePath: () => location + "", getCurrentDirectory: returnEmptyString, // For inferred project root if projectRoot path is not set, normalizing the paths /* eslint-disable no-restricted-globals */ From dd331e25191806fc6556b08581db02d6a34f128a Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 28 Sep 2020 17:04:09 -0700 Subject: [PATCH 09/26] Update src/tsserver/webServer.ts Co-authored-by: Sheetal Nandi --- src/tsserver/webServer.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index a45b8b6261e6c..e8aff170cdd6d 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -111,10 +111,7 @@ namespace ts.server { return false; }, readDirectory: notImplemented, // Configured project, typing installer - getDirectories: (path: string) => { - console.log("paths", path); - return []; - }, // For automatic type reference directives + getDirectories: () => [], // For automatic type reference directives createDirectory: notImplemented, // compile On save writeFile: notImplemented, // compile on save resolvePath: identity, // Plugins From 2d4c7263f3da7077dc6ec25ece009e64de2bbdce Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 28 Sep 2020 17:06:24 -0700 Subject: [PATCH 10/26] Addressing feedback --- src/tsserver/webServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index e8aff170cdd6d..0098f1b060155 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -55,7 +55,7 @@ namespace ts.server { const sys: ServerHost = { args, - newLine: "\n", // This can be configured by clients + 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: string, _encoding?: string): string | undefined => { if (!path.startsWith("http:") && !path.startsWith("https:")) { From 69a65232712306f54f8bee4626e81a271c9e7d36 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Tue, 29 Sep 2020 16:45:18 -0700 Subject: [PATCH 11/26] Allow passing in explicit executingFilePath This is required for cases where `self.location` does not point to the directory where all the typings are stored --- src/tsserver/webServer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 0098f1b060155..1f227068683d6 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -77,7 +77,9 @@ namespace ts.server { watchFile: returnNoopFileWatcher, watchDirectory: returnNoopFileWatcher, - getExecutingFilePath: () => location + "", + getExecutingFilePath: () => { + return findArgument("--executingFilePath") || location + ""; + }, getCurrentDirectory: returnEmptyString, // For inferred project root if projectRoot path is not set, normalizing the paths /* eslint-disable no-restricted-globals */ From 5185f6ee250675cdbd2b7914cd7da62e9d1cd578 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Tue, 29 Sep 2020 17:58:47 -0700 Subject: [PATCH 12/26] Adding logging support --- src/tsserver/webServer.ts | 126 +++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 3 deletions(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 1f227068683d6..232c76ed4c3ad 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -1,10 +1,11 @@ -/*@internak*/ +/*@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, @@ -18,6 +19,91 @@ namespace ts.server { getLogFileName: returnUndefined, }; + // Save off original versions before they are overwitten + const consoleLog = console.log.bind(console); + const consoleError = console.error.bind(console); + + class ConsoleLogger implements Logger { + + private readonly topLevelGroupName = "TS Server"; + + private currentGroupCount = 0; + private seq = 0; + + constructor( + private readonly level: LogLevel + ) { } + + close(): void { + // noop + } + + hasLevel(level: LogLevel): boolean { + return this.level >= level; + } + + loggingEnabled(): boolean { + return true; + } + + perftrc(s: string): void { + this.msg(s, Msg.Perf); + } + + info(s: string): void { + this.msg(s, Msg.Info); + } + + err(s: string) { + this.msg(s, Msg.Err); + } + + startGroup(): void { + ++this.currentGroupCount; + } + + endGroup(): void { + this.currentGroupCount = Math.max(0, this.currentGroupCount - 1); + } + + msg(s: string, type: Msg = Msg.Err): void { + s = `${type} ${this.seq.toString()} [${nowString()}] ${s}`; + + switch (type) { + case Msg.Info: + this.write(() => { consoleLog(s); }); + break; + + case Msg.Perf: + this.write(() => { consoleLog(s); }); + break; + + case Msg.Err: + default: + this.write(() => { consoleError(s); }); + break; + } + + if (this.currentGroupCount === 0) { + this.seq++; + } + } + + getLogFileName(): string | undefined { + return undefined; + } + + private write(f: () => void) { + console.group(this.topLevelGroupName); + try { + f(); + } + finally { + console.groupEnd(); + } + } + } + let unknownServerMode: string | undefined; function parseServerMode(): LanguageServiceMode | undefined { const mode = findArgument("--serverMode"); @@ -41,7 +127,7 @@ namespace ts.server { const serverMode = parseServerMode(); return { args, - logger: nullLogger, + logger: createLogger(), cancellationToken: nullCancellationToken, serverMode, unknownServerMode, @@ -49,6 +135,25 @@ namespace ts.server { }; } + function createLogger() { + const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); + return typeof cmdLineVerbosity === "undefined" + ? nullLogger + : new ConsoleLogger(cmdLineVerbosity); + } + + function getLogLevel(level: string | undefined) { + if (level) { + const l = level.toLowerCase(); + for (const name in LogLevel) { + if (isNaN(+name) && l === name.toLowerCase()) { + return LogLevel[name]; + } + } + } + return undefined; + } + function createWebSystem(args: string[]) { Debug.assert(ts.sys === undefined); const returnEmptyString = () => ""; @@ -130,6 +235,21 @@ namespace ts.server { // TODO:: Locale setting? } + 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 (typeof previous === "number") { + 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 Session<{}> { constructor() { @@ -141,7 +261,7 @@ namespace ts.server { ...options, typingsInstaller: nullTypingsInstaller, byteLength: notImplemented, // Formats the message text in send of Session which is override in this class so not needed - hrtime: notImplemented, // Needed for perf logging which is disabled anyway + hrtime, logger, canUseEvents: false, }); From 1db3ebcb15a0af0d041e2aedefcd273391ee2a2b Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Thu, 1 Oct 2020 15:03:04 -0700 Subject: [PATCH 13/26] Do not create auto import provider in partial semantic mode --- src/server/project.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/project.ts b/src/server/project.ts index cac9eefb2913e..7d262488efee2 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -1672,6 +1672,10 @@ namespace ts.server { if (this.autoImportProviderHost === false) { return undefined; } + if (this.projectService.serverMode !== LanguageServiceMode.Semantic) { + this.autoImportProviderHost = false; + return undefined; + } if (this.autoImportProviderHost) { updateProjectIfDirty(this.autoImportProviderHost); if (!this.autoImportProviderHost.hasRoots()) { From 9a1eace7d9d1253b1092731cb10352c4b8703163 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Thu, 1 Oct 2020 15:43:45 -0700 Subject: [PATCH 14/26] Handle lib files by doing path mapping instead --- src/server/editorServices.ts | 4 +-- src/tsserver/nodeServer.ts | 12 -------- src/tsserver/server.ts | 12 ++++++++ src/tsserver/webServer.ts | 58 ++++++++++++------------------------ 4 files changed, 33 insertions(+), 53 deletions(-) diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index d58ec139af073..9c8c198e1b245 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -2489,7 +2489,7 @@ namespace ts.server { } private getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(fileName: NormalizedPath, currentDirectory: string, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined, hostToQueryFileExistsOn: DirectoryStructureHost | undefined) { - if (isRootedDiskPath(fileName) || isDynamicFileName(fileName) || isUrl(fileName)) { + if (isRootedDiskPath(fileName) || isDynamicFileName(fileName)) { return this.getOrCreateScriptInfoWorker(fileName, currentDirectory, /*openedByClient*/ false, /*fileContent*/ undefined, scriptKind, hasMixedContent, hostToQueryFileExistsOn); } @@ -2519,7 +2519,7 @@ namespace ts.server { let info = this.getScriptInfoForPath(path); if (!info) { const isDynamic = isDynamicFileName(fileName); - Debug.assert(isRootedDiskPath(fileName) || isUrl(fileName) || isDynamic || openedByClient, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nScript info with non-dynamic relative file name can only be open script info or in context of host currentDirectory`); + Debug.assert(isRootedDiskPath(fileName) || isDynamic || openedByClient, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nScript info with non-dynamic relative file name can only be open script info or in context of host currentDirectory`); Debug.assert(!isRootedDiskPath(fileName) || this.currentDirectory === currentDirectory || !this.openFilesWithNonRootedDiskPath.has(this.toCanonicalFileName(fileName)), "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nOpen script files with non rooted disk path opened with current directory context cannot have same canonical names`); Debug.assert(!isDynamic || this.currentDirectory === currentDirectory || this.useInferredProjectPerProjectRoot, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nDynamic files must always be opened with service's current directory or service should support inferred project per projectRootPath.`); // If the file is not opened by client and the file doesnot exist on the disk, return diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts index da424cb073ab2..6d2286d35df39 100644 --- a/src/tsserver/nodeServer.ts +++ b/src/tsserver/nodeServer.ts @@ -72,18 +72,6 @@ namespace ts.server { } } - function getLogLevel(level: string | undefined) { - if (level) { - const l = level.toLowerCase(); - for (const name in LogLevel) { - if (isNaN(+name) && l === name.toLowerCase()) { - return LogLevel[name]; - } - } - } - return undefined; - } - let unknownServerMode: string | undefined; function parseServerMode(): LanguageServiceMode | undefined { const mode = findArgument("--serverMode"); diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index 23d13aea7d274..ed3104d741b48 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -35,6 +35,18 @@ namespace ts.server { return arg.split(",").filter(name => name !== ""); } + export function getLogLevel(level: string | undefined) { + if (level) { + const l = level.toLowerCase(); + for (const name in LogLevel) { + if (isNaN(+name) && l === name.toLowerCase()) { + return LogLevel[name]; + } + } + } + return undefined; + } + export interface StartInput { args: readonly string[]; logger: Logger; diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 232c76ed4c3ad..99e3ef8fd7b79 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -137,54 +137,33 @@ namespace ts.server { function createLogger() { const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); - return typeof cmdLineVerbosity === "undefined" - ? nullLogger - : new ConsoleLogger(cmdLineVerbosity); - } - - function getLogLevel(level: string | undefined) { - if (level) { - const l = level.toLowerCase(); - for (const name in LogLevel) { - if (isNaN(+name) && l === name.toLowerCase()) { - return LogLevel[name]; - } - } - } - return undefined; + return typeof cmdLineVerbosity === "undefined" ? nullLogger : new ConsoleLogger(cmdLineVerbosity); } function createWebSystem(args: string[]) { Debug.assert(ts.sys === undefined); const returnEmptyString = () => ""; - + // 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, executingDirectoryPath) : undefined; const sys: ServerHost = { 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: string, _encoding?: string): string | undefined => { - if (!path.startsWith("http:") && !path.startsWith("https:")) { - return undefined; - } + const webPath = getWebPath(path); + if (!webPath) return undefined; const request = new XMLHttpRequest(); - request.open("GET", path, /* asynchronous */ false); + request.open("GET", webPath, /* asynchronous */ false); request.send(); - - if (request.status !== 200) { - return undefined; - } - - return request.responseText; + return request.status === 200 ? request.responseText : undefined; }, write: postMessage, watchFile: returnNoopFileWatcher, watchDirectory: returnNoopFileWatcher, - getExecutingFilePath: () => { - return findArgument("--executingFilePath") || location + ""; - }, + getExecutingFilePath: () => directorySeparator, getCurrentDirectory: returnEmptyString, // For inferred project root if projectRoot path is not set, normalizing the paths /* eslint-disable no-restricted-globals */ @@ -204,19 +183,15 @@ namespace ts.server { // For semantic server mode fileExists: (path: string): boolean => { - if (!path.startsWith("http:") && !path.startsWith("https:")) { - return false; - } + const webPath = getWebPath(path); + if (!webPath) return false; const request = new XMLHttpRequest(); - request.open("HEAD", path, /* asynchronous */ false); + request.open("HEAD", webPath, /* asynchronous */ false); request.send(); - return request.status === 200; }, - directoryExists: (_path: string): boolean => { - return false; - }, + directoryExists: returnFalse, // Module resolution readDirectory: notImplemented, // Configured project, typing installer getDirectories: () => [], // For automatic type reference directives createDirectory: notImplemented, // compile On save @@ -232,7 +207,12 @@ namespace ts.server { // getMemoryUsage?(): number; }; ts.sys = sys; - // TODO:: Locale setting? + // Do this after sys has been set as findArguments is going to work only then + const executingDirectoryPath = ensureTrailingDirectorySeparator(getDirectoryPath(findArgument("--executingFilePath") || location + "")); + const localeStr = findArgument("--locale"); + if (localeStr) { + validateLocaleAndSetLanguage(localeStr, sys); + } } function hrtime(previous?: [number, number]) { @@ -276,7 +256,7 @@ namespace ts.server { } protected toStringMessage(message: {}) { - return message.toString(); + return JSON.stringify(message, undefined, 2); } exit() { From 04a4fe75563ea9fe4747d42a4448d5dd421fbba5 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 27 Oct 2020 15:38:19 -0700 Subject: [PATCH 15/26] TODO --- src/tsserver/webServer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 99e3ef8fd7b79..6ad41d036407b 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -270,6 +270,8 @@ namespace ts.server { this.onMessage(message.data); }); } + + // TODO:: Update all responses to use webPath } const session = new WorkerSession(); From b959f3e3e4a3c9c5125626175ef05e50f5761ba1 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 11 Nov 2020 17:33:18 -0800 Subject: [PATCH 16/26] Add log message This replaces the console based logger with a logger that post log messages back to the host. VS Code will write these messages to its output window --- src/tsserver/webServer.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 6ad41d036407b..ff3cd04992860 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -19,13 +19,15 @@ namespace ts.server { getLogFileName: returnUndefined, }; - // Save off original versions before they are overwitten - const consoleLog = console.log.bind(console); - const consoleError = console.error.bind(console); + type MessageLogLevel = "info" | "perf" | "error"; - class ConsoleLogger implements Logger { + interface LoggingMessage { + readonly type: "log"; + readonly level: MessageLogLevel; + readonly body: string + } - private readonly topLevelGroupName = "TS Server"; + class MainProcessLogger implements Logger { private currentGroupCount = 0; private seq = 0; @@ -71,16 +73,16 @@ namespace ts.server { switch (type) { case Msg.Info: - this.write(() => { consoleLog(s); }); + this.write("info", s); break; case Msg.Perf: - this.write(() => { consoleLog(s); }); + this.write("perf", s); break; case Msg.Err: default: - this.write(() => { consoleError(s); }); + this.write("error", s); break; } @@ -93,14 +95,12 @@ namespace ts.server { return undefined; } - private write(f: () => void) { - console.group(this.topLevelGroupName); - try { - f(); - } - finally { - console.groupEnd(); - } + private write(level: MessageLogLevel, body: string) { + postMessage({ + type: "log", + level, + body, + }); } } @@ -137,7 +137,7 @@ namespace ts.server { function createLogger() { const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); - return typeof cmdLineVerbosity === "undefined" ? nullLogger : new ConsoleLogger(cmdLineVerbosity); + return typeof cmdLineVerbosity === "undefined" ? nullLogger : new MainProcessLogger(cmdLineVerbosity); } function createWebSystem(args: string[]) { From 2359c8398ee3b2058f280efabd70d0a0f6751ba8 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Fri, 13 Nov 2020 14:50:45 -0800 Subject: [PATCH 17/26] Move code around so that exported functions are set on namespace --- src/tsserver/server.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index ed3104d741b48..c3933150fdf07 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -7,26 +7,6 @@ namespace ts.server { Web }; - const systemKind = typeof process !== "undefined" ? SystemKind.Node : SystemKind.Web; - const platform = () => systemKind === SystemKind.Web ? "web" : require("os").platform(); - setStackTraceLimit(); - switch (systemKind) { - case SystemKind.Node: - start(initializeNodeSystem()); - break; - case SystemKind.Web: - // Get args from first message - const listener = (e: any) => { - removeEventListener("message", listener); - const args = e.data; - start(initializeWebSystem(args)); - }; - addEventListener("message", listener); - break; - default: - Debug.assertNever(systemKind, "Unknown system kind"); - } - function parseStringArray(argName: string): readonly string[] { const arg = findArgument(argName); if (arg === undefined) { @@ -109,4 +89,24 @@ namespace ts.server { cancellationToken ); } + + const systemKind = typeof process !== "undefined" ? SystemKind.Node : SystemKind.Web; + const platform = () => systemKind === SystemKind.Web ? "web" : require("os").platform(); + setStackTraceLimit(); + switch (systemKind) { + case SystemKind.Node: + start(initializeNodeSystem()); + break; + case SystemKind.Web: + // Get args from first message + const listener = (e: any) => { + removeEventListener("message", listener); + const args = e.data; + start(initializeWebSystem(args)); + }; + addEventListener("message", listener); + break; + default: + Debug.assertNever(systemKind, "Unknown system kind"); + } } From f29b2e773231b246449d2d3e7dd517256db9f58c Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Fri, 13 Nov 2020 15:02:48 -0800 Subject: [PATCH 18/26] Log response --- src/tsserver/webServer.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index ff3cd04992860..340eaff70efb6 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -248,6 +248,15 @@ namespace ts.server { } 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)) { + logger.info(`${msg.type}:${indent(JSON.stringify(msg))}`); + } postMessage(msg); } From 0edf650622da11e89e42238523d57f3ea780cdcf Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Fri, 13 Nov 2020 16:05:32 -0800 Subject: [PATCH 19/26] Map the paths back to https: // TODO: is this really needed or can vscode take care of this How do we handle when opening lib.d.ts as response to goto def in open files --- src/tsserver/webServer.ts | 40 +++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 340eaff70efb6..9167dfc1f4560 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -140,12 +140,17 @@ namespace ts.server { return typeof cmdLineVerbosity === "undefined" ? nullLogger : new MainProcessLogger(cmdLineVerbosity); } + interface WebServerHost extends ServerHost { + getWebPath: (path: string) => string | undefined; + } + function createWebSystem(args: string[]) { Debug.assert(ts.sys === undefined); const returnEmptyString = () => ""; // 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, executingDirectoryPath) : undefined; - const sys: ServerHost = { + const sys: WebServerHost = { + getWebPath, 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 @@ -233,10 +238,8 @@ namespace ts.server { function startWebSession(options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) { class WorkerSession extends Session<{}> { constructor() { - const host = sys as ServerHost; - super({ - host, + host: sys as WebServerHost, cancellationToken, ...options, typingsInstaller: nullTypingsInstaller, @@ -248,6 +251,9 @@ namespace ts.server { } public send(msg: protocol.Message) { + // Updates to file paths + this.updateWebPaths(msg); + 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)}`); @@ -260,6 +266,30 @@ namespace ts.server { postMessage(msg); } + private updateWebPaths(obj: any) { + if (isArray(obj)) { + obj.forEach(ele => this.updateWebPaths(ele)); + } + else if (typeof obj === "object") { + for (const id in obj) { + if (hasProperty(obj, id)) { + const value = obj[id]; + if ((id === "file" || id === "fileName" || id === "renameFilename") && isString(value)) { + const webpath = (sys as WebServerHost).getWebPath(value); + if (webpath) obj[id] = webpath; + } + else if ((id === "files" || id === "fileNames") && isArray(value) && value.every(isString)) { + obj[id] = value.map(ele => (sys as WebServerHost).getWebPath(ele) || ele); + } + else { + this.updateWebPaths(value); + } + } + } + + } + } + protected parseMessage(message: {}): protocol.Request { return message; } @@ -279,8 +309,6 @@ namespace ts.server { this.onMessage(message.data); }); } - - // TODO:: Update all responses to use webPath } const session = new WorkerSession(); From 08205196f2882d65a34d3d151155869e2d84de61 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Fri, 13 Nov 2020 16:41:08 -0800 Subject: [PATCH 20/26] If files are not open dont schedule open file project ensure --- src/server/editorServices.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 2747a6f03f6b8..e110d0a589ec2 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -923,6 +923,7 @@ namespace ts.server { /*@internal*/ delayEnsureProjectForOpenFiles() { + if (!this.openFiles.size) return; this.pendingEnsureProjectForOpenFiles = true; this.throttledOperations.schedule("*ensureProjectForOpenFiles*", /*delay*/ 2500, () => { if (this.pendingProjectUpdates.size !== 0) { From 7e4939014a414c7651f1fa01516c81a37a10e9be Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Fri, 13 Nov 2020 17:03:44 -0800 Subject: [PATCH 21/26] Should also check if there are no external projects before skipping scheduling Fixes failing tests --- src/server/editorServices.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index e110d0a589ec2..09fa94142534a 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -923,7 +923,8 @@ namespace ts.server { /*@internal*/ delayEnsureProjectForOpenFiles() { - if (!this.openFiles.size) return; + // If no open files or no external project, do not schedule + if (!this.openFiles.size && !this.externalProjects.length && !this.externalProjectToConfiguredProjectMap.size) return; this.pendingEnsureProjectForOpenFiles = true; this.throttledOperations.schedule("*ensureProjectForOpenFiles*", /*delay*/ 2500, () => { if (this.pendingProjectUpdates.size !== 0) { From 16ff1ce041fd1dc4f2419c7b9fbba65694a318ea Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 18 Nov 2020 10:49:34 -0800 Subject: [PATCH 22/26] Revert "Map the paths back to https:" This reverts commit 0edf650622da11e89e42238523d57f3ea780cdcf. --- src/tsserver/webServer.ts | 40 ++++++--------------------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 9167dfc1f4560..340eaff70efb6 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -140,17 +140,12 @@ namespace ts.server { return typeof cmdLineVerbosity === "undefined" ? nullLogger : new MainProcessLogger(cmdLineVerbosity); } - interface WebServerHost extends ServerHost { - getWebPath: (path: string) => string | undefined; - } - function createWebSystem(args: string[]) { Debug.assert(ts.sys === undefined); const returnEmptyString = () => ""; // 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, executingDirectoryPath) : undefined; - const sys: WebServerHost = { - getWebPath, + const sys: ServerHost = { 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 @@ -238,8 +233,10 @@ namespace ts.server { function startWebSession(options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) { class WorkerSession extends Session<{}> { constructor() { + const host = sys as ServerHost; + super({ - host: sys as WebServerHost, + host, cancellationToken, ...options, typingsInstaller: nullTypingsInstaller, @@ -251,9 +248,6 @@ namespace ts.server { } public send(msg: protocol.Message) { - // Updates to file paths - this.updateWebPaths(msg); - 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)}`); @@ -266,30 +260,6 @@ namespace ts.server { postMessage(msg); } - private updateWebPaths(obj: any) { - if (isArray(obj)) { - obj.forEach(ele => this.updateWebPaths(ele)); - } - else if (typeof obj === "object") { - for (const id in obj) { - if (hasProperty(obj, id)) { - const value = obj[id]; - if ((id === "file" || id === "fileName" || id === "renameFilename") && isString(value)) { - const webpath = (sys as WebServerHost).getWebPath(value); - if (webpath) obj[id] = webpath; - } - else if ((id === "files" || id === "fileNames") && isArray(value) && value.every(isString)) { - obj[id] = value.map(ele => (sys as WebServerHost).getWebPath(ele) || ele); - } - else { - this.updateWebPaths(value); - } - } - } - - } - } - protected parseMessage(message: {}): protocol.Request { return message; } @@ -309,6 +279,8 @@ namespace ts.server { this.onMessage(message.data); }); } + + // TODO:: Update all responses to use webPath } const session = new WorkerSession(); From 9952bab926c48e41032b8181229c16ae23ab8d05 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 18 Nov 2020 10:49:55 -0800 Subject: [PATCH 23/26] Revert "TODO" This reverts commit 04a4fe75563ea9fe4747d42a4448d5dd421fbba5. --- src/tsserver/webServer.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index 340eaff70efb6..006c6651c5802 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -279,8 +279,6 @@ namespace ts.server { this.onMessage(message.data); }); } - - // TODO:: Update all responses to use webPath } const session = new WorkerSession(); From 2bf35c9d4cf75bf5440c8ce47985063c377750ec Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 18 Nov 2020 15:42:02 -0800 Subject: [PATCH 24/26] Revert "Should also check if there are no external projects before skipping scheduling" This reverts commit 7e4939014a414c7651f1fa01516c81a37a10e9be. --- src/server/editorServices.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 09fa94142534a..e110d0a589ec2 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -923,8 +923,7 @@ namespace ts.server { /*@internal*/ delayEnsureProjectForOpenFiles() { - // If no open files or no external project, do not schedule - if (!this.openFiles.size && !this.externalProjects.length && !this.externalProjectToConfiguredProjectMap.size) return; + if (!this.openFiles.size) return; this.pendingEnsureProjectForOpenFiles = true; this.throttledOperations.schedule("*ensureProjectForOpenFiles*", /*delay*/ 2500, () => { if (this.pendingProjectUpdates.size !== 0) { From 542a8b0df4e404ebe2b475a70ccf4ac28b1894f5 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Fri, 20 Nov 2020 17:20:54 -0800 Subject: [PATCH 25/26] Refactoring so we can test the changes out --- Gulpfile.js | 2 + src/testRunner/tsconfig.json | 2 + .../unittests/tsserver/webServer.ts | 157 ++++++++++++++ src/tsserver/server.ts | 11 - src/tsserver/tsconfig.json | 1 + src/tsserver/webServer.ts | 189 ++--------------- src/webServer/tsconfig.json | 20 ++ src/webServer/webServer.ts | 193 ++++++++++++++++++ 8 files changed, 389 insertions(+), 186 deletions(-) create mode 100644 src/testRunner/unittests/tsserver/webServer.ts create mode 100644 src/webServer/tsconfig.json create mode 100644 src/webServer/webServer.ts diff --git a/Gulpfile.js b/Gulpfile.js index 82af1630c5cfc..10d1ffc86c9ef 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/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..0fc2d3753fde7 --- /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.HostWithPostMessage, 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/server.ts b/src/tsserver/server.ts index c3933150fdf07..c62accea92be0 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -35,17 +35,6 @@ namespace ts.server { unknownServerMode?: string; startSession: (option: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) => void; } - 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"]; - } function start({ args, logger, cancellationToken, serverMode, unknownServerMode, startSession: startServer }: StartInput) { const syntaxOnly = hasArgument("--syntaxOnly"); diff --git a/src/tsserver/tsconfig.json b/src/tsserver/tsconfig.json index f3815be83af8d..6643ecf2f1b7a 100644 --- a/src/tsserver/tsconfig.json +++ b/src/tsserver/tsconfig.json @@ -17,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 index 006c6651c5802..e0e613161a006 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -19,91 +19,6 @@ namespace ts.server { getLogFileName: returnUndefined, }; - type MessageLogLevel = "info" | "perf" | "error"; - - interface LoggingMessage { - readonly type: "log"; - readonly level: MessageLogLevel; - readonly body: string - } - - class MainProcessLogger implements Logger { - - private currentGroupCount = 0; - private seq = 0; - - constructor( - private readonly level: LogLevel - ) { } - - close(): void { - // noop - } - - hasLevel(level: LogLevel): boolean { - return this.level >= level; - } - - loggingEnabled(): boolean { - return true; - } - - perftrc(s: string): void { - this.msg(s, Msg.Perf); - } - - info(s: string): void { - this.msg(s, Msg.Info); - } - - err(s: string) { - this.msg(s, Msg.Err); - } - - startGroup(): void { - ++this.currentGroupCount; - } - - endGroup(): void { - this.currentGroupCount = Math.max(0, this.currentGroupCount - 1); - } - - msg(s: string, type: Msg = Msg.Err): void { - s = `${type} ${this.seq.toString()} [${nowString()}] ${s}`; - - switch (type) { - case Msg.Info: - this.write("info", s); - break; - - case Msg.Perf: - this.write("perf", s); - break; - - case Msg.Err: - default: - this.write("error", s); - break; - } - - if (this.currentGroupCount === 0) { - this.seq++; - } - } - - getLogFileName(): string | undefined { - return undefined; - } - - private write(level: MessageLogLevel, body: string) { - postMessage({ - type: "log", - level, - body, - }); - } - } - let unknownServerMode: string | undefined; function parseServerMode(): LanguageServiceMode | undefined { const mode = findArgument("--serverMode"); @@ -137,78 +52,33 @@ namespace ts.server { function createLogger() { const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); - return typeof cmdLineVerbosity === "undefined" ? nullLogger : new MainProcessLogger(cmdLineVerbosity); + return typeof cmdLineVerbosity === "undefined" ? nullLogger : new MainProcessLogger(cmdLineVerbosity, { writeMessage }); + } + + function writeMessage(s: any) { + postMessage(s); } function createWebSystem(args: string[]) { Debug.assert(ts.sys === undefined); - const returnEmptyString = () => ""; - // 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, executingDirectoryPath) : undefined; - const sys: ServerHost = { - 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: string, _encoding?: string): string | undefined => { - const webPath = getWebPath(path); - if (!webPath) return 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; }, - - write: postMessage, - 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: string): boolean => { - const webPath = getWebPath(path); - if (!webPath) return false; - + fileExists: webPath => { const request = new XMLHttpRequest(); request.open("HEAD", webPath, /* asynchronous */ false); request.send(); return request.status === 200; }, - 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; + writeMessage, }; - ts.sys = sys; // Do this after sys has been set as findArguments is going to work only then - const executingDirectoryPath = ensureTrailingDirectorySeparator(getDirectoryPath(findArgument("--executingFilePath") || location + "")); + const sys = server.createWebSystem(webHost, args, () => findArgument("--executingFilePath") || location + ""); + ts.sys = sys; const localeStr = findArgument("--locale"); if (localeStr) { validateLocaleAndSetLanguage(localeStr, sys); @@ -231,41 +101,10 @@ namespace ts.server { } function startWebSession(options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) { - class WorkerSession extends Session<{}> { + debugger; + class WorkerSession extends server.WorkerSession { constructor() { - const host = sys as ServerHost; - - super({ - host, - cancellationToken, - ...options, - typingsInstaller: nullTypingsInstaller, - byteLength: notImplemented, // Formats the message text in send of Session which is override 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)) { - logger.info(`${msg.type}:${indent(JSON.stringify(msg))}`); - } - postMessage(msg); - } - - protected parseMessage(message: {}): protocol.Request { - return message; - } - - protected toStringMessage(message: {}) { - return JSON.stringify(message, undefined, 2); + super(sys as ServerHost, { writeMessage }, options, logger, cancellationToken, hrtime); } exit() { 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..a753afc103d72 --- /dev/null +++ b/src/webServer/webServer.ts @@ -0,0 +1,193 @@ +/*@internal*/ +namespace ts.server { + export interface HostWithPostMessage { + writeMessage(s: any): void; + } + export interface WebHost extends HostWithPostMessage { + readFile(path: string): string | undefined; + fileExists(path: string): boolean; + } + + export type MessageLogLevel = "info" | "perf" | "error"; + export interface LoggingMessage { + readonly type: "log"; + readonly level: MessageLogLevel; + readonly body: string + } + export class MainProcessLogger implements Logger { + private currentGroupCount = 0; + private seq = 0; + close = noop; + + constructor(private readonly level: LogLevel, private host: HostWithPostMessage) { + } + + hasLevel(level: LogLevel): boolean { + return this.level >= level; + } + + loggingEnabled(): boolean { + return true; + } + + perftrc(s: string): void { + this.msg(s, Msg.Perf); + } + + info(s: string): void { + this.msg(s, Msg.Info); + } + + err(s: string) { + this.msg(s, Msg.Err); + } + + startGroup(): void { + ++this.currentGroupCount; + } + + endGroup(): void { + this.currentGroupCount = Math.max(0, this.currentGroupCount - 1); + } + + msg(s: string, type: Msg = Msg.Err): void { + s = `${type} ${this.seq.toString()} [${nowString()}] ${s}`; + + switch (type) { + case Msg.Info: + this.write("info", s); + break; + + case Msg.Perf: + this.write("perf", s); + break; + + case Msg.Err: + default: + this.write("error", s); + break; + } + + if (this.currentGroupCount === 0) { + this.seq++; + } + } + + getLogFileName(): string | undefined { + return undefined; + } + + private write(level: MessageLogLevel, body: string) { + 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: HostWithPostMessage, 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 override 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); + } + } +} From 245e49d0394b1e90344771556632e045bce193a3 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 1 Dec 2020 18:05:36 -0800 Subject: [PATCH 26/26] Feedback --- src/server/session.ts | 8 +- .../unittests/tsserver/webServer.ts | 2 +- src/tsserver/nodeServer.ts | 90 +++---------- src/tsserver/server.ts | 44 +++---- src/tsserver/webServer.ts | 37 +++--- src/webServer/webServer.ts | 119 +++++++++++------- .../reference/api/tsserverlibrary.d.ts | 8 +- 7 files changed, 133 insertions(+), 175 deletions(-) diff --git a/src/server/session.ts b/src/server/session.ts index 195f9fb2d4407..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: MessageType) { + public onMessage(message: TMessage) { this.gcTimer.scheduleCollect(); this.performanceData = undefined; @@ -2978,11 +2978,11 @@ namespace ts.server { } } - protected parseMessage(message: MessageType): protocol.Request { + protected parseMessage(message: TMessage): protocol.Request { return JSON.parse(message as any as string); } - protected toStringMessage(message: MessageType): string { + protected toStringMessage(message: TMessage): string { return message as any as string; } diff --git a/src/testRunner/unittests/tsserver/webServer.ts b/src/testRunner/unittests/tsserver/webServer.ts index 0fc2d3753fde7..5ea6f48a8a92b 100644 --- a/src/testRunner/unittests/tsserver/webServer.ts +++ b/src/testRunner/unittests/tsserver/webServer.ts @@ -1,7 +1,7 @@ namespace ts.projectSystem { describe("unittests:: tsserver:: webServer", () => { class TestWorkerSession extends server.WorkerSession { - constructor(host: server.ServerHost, webHost: server.HostWithPostMessage, options: Partial, logger: server.Logger) { + constructor(host: server.ServerHost, webHost: server.HostWithWriteMessage, options: Partial, logger: server.Logger) { super( host, webHost, diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts index 8e8e4845d7a1b..74d12e7cf06c8 100644 --- a/src/tsserver/nodeServer.ts +++ b/src/tsserver/nodeServer.ts @@ -72,12 +72,9 @@ namespace ts.server { } } - let unknownServerMode: string | undefined; - function parseServerMode(): LanguageServiceMode | undefined { + function parseServerMode(): LanguageServiceMode | string | undefined { const mode = findArgument("--serverMode"); - if (mode === undefined) { - return undefined; - } + if (!mode) return undefined; switch (mode.toLowerCase()) { case "semantic": @@ -87,8 +84,7 @@ namespace ts.server { case "syntactic": return LanguageServiceMode.Syntactic; default: - unknownServerMode = mode; - return undefined; + return mode; } } @@ -130,15 +126,14 @@ namespace ts.server { stat(path: string, callback?: (err: NodeJS.ErrnoException, stats: Stats) => any): void; } = require("fs"); - class Logger implements server.Logger { // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier + class Logger extends BaseLogger { private fd = -1; - private seq = 0; - private inGroup = false; - private firstInGroup = true; - - constructor(private readonly logFilename: string, + constructor( + private readonly logFilename: string, private readonly traceToConsole: boolean, - private readonly level: LogLevel) { + level: LogLevel + ) { + super(level); if (this.logFilename) { try { this.fd = fs.openSync(this.logFilename, "w"); @@ -149,10 +144,6 @@ namespace ts.server { } } - static padStringRight(str: string, padding: string) { - return (str + padding).slice(0, padding.length); - } - close() { if (this.fd >= 0) { fs.close(this.fd, noop); @@ -163,66 +154,15 @@ namespace ts.server { 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() { + protected canWrite() { return this.fd >= 0 || this.traceToConsole; } - private write(s: string) { + protected write(s: string, _type: Msg) { if (this.fd >= 0) { const buf = sys.bufferFrom!(s); // eslint-disable-next-line no-null/no-null @@ -349,7 +289,13 @@ namespace ts.server { validateLocaleAndSetLanguage(localeStr, sys); } - const serverMode = parseServerMode(); + 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, diff --git a/src/tsserver/server.ts b/src/tsserver/server.ts index c62accea92be0..7bb13ca46a2e8 100644 --- a/src/tsserver/server.ts +++ b/src/tsserver/server.ts @@ -2,12 +2,7 @@ namespace ts.server { declare const addEventListener: any; declare const removeEventListener: any; - const enum SystemKind { - Node, - Web - }; - - function parseStringArray(argName: string): readonly string[] { + function findArgumentStringArray(argName: string): readonly string[] { const arg = findArgument(argName); if (arg === undefined) { return emptyArray; @@ -35,13 +30,13 @@ namespace ts.server { unknownServerMode?: string; startSession: (option: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) => void; } - function start({ args, logger, cancellationToken, serverMode, unknownServerMode, startSession: startServer }: StartInput) { + function start({ args, logger, cancellationToken, serverMode, unknownServerMode, startSession: startServer }: StartInput, platform: string) { const syntaxOnly = hasArgument("--syntaxOnly"); 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(`Platform: ${platform} NodeVersion: ${getNodeMajorVersion()} CaseSensitive: ${sys.useCaseSensitiveFileNames}`); logger.info(`ServerMode: ${serverMode} syntaxOnly: ${syntaxOnly} hasUnknownServerMode: ${unknownServerMode}`); setStackTraceLimit(); @@ -64,8 +59,8 @@ namespace ts.server { startServer( { - globalPlugins: parseStringArray("--globalPlugins"), - pluginProbeLocations: parseStringArray("--pluginProbeLocations"), + globalPlugins: findArgumentStringArray("--globalPlugins"), + pluginProbeLocations: findArgumentStringArray("--pluginProbeLocations"), allowLocalPluginLoads: hasArgument("--allowLocalPluginLoads"), useSingleInferredProject: hasArgument("--useSingleInferredProject"), useInferredProjectPerProjectRoot: hasArgument("--useInferredProjectPerProjectRoot"), @@ -79,23 +74,18 @@ namespace ts.server { ); } - const systemKind = typeof process !== "undefined" ? SystemKind.Node : SystemKind.Web; - const platform = () => systemKind === SystemKind.Web ? "web" : require("os").platform(); setStackTraceLimit(); - switch (systemKind) { - case SystemKind.Node: - start(initializeNodeSystem()); - break; - case SystemKind.Web: - // Get args from first message - const listener = (e: any) => { - removeEventListener("message", listener); - const args = e.data; - start(initializeWebSystem(args)); - }; - addEventListener("message", listener); - break; - default: - Debug.assertNever(systemKind, "Unknown system kind"); + // Cannot check process var directory in webworker so has to be typeof check here + if (typeof process !== "undefined") { + start(initializeNodeSystem(), require("os").platform()); + } + else { + // Get args from first message + const listener = (e: any) => { + removeEventListener("message", listener); + const args = e.data; + start(initializeWebSystem(args), "web"); + }; + addEventListener("message", listener); } } diff --git a/src/tsserver/webServer.ts b/src/tsserver/webServer.ts index e0e613161a006..dced32152b75b 100644 --- a/src/tsserver/webServer.ts +++ b/src/tsserver/webServer.ts @@ -19,32 +19,32 @@ namespace ts.server { getLogFileName: returnUndefined, }; - let unknownServerMode: string | undefined; - function parseServerMode(): LanguageServiceMode | undefined { + function parseServerMode(): LanguageServiceMode | string | undefined { const mode = findArgument("--serverMode"); - if (mode !== undefined) { - switch (mode.toLowerCase()) { - case "partialsemantic": - return LanguageServiceMode.PartialSemantic; - case "syntactic": - return LanguageServiceMode.Syntactic; - default: - unknownServerMode = mode; - break; - } + if (!mode) return undefined; + switch (mode.toLowerCase()) { + case "partialsemantic": + return LanguageServiceMode.PartialSemantic; + case "syntactic": + return LanguageServiceMode.Syntactic; + default: + return mode; } - // Webserver defaults to partial semantic mode - return hasArgument("--syntaxOnly") ? LanguageServiceMode.Syntactic : LanguageServiceMode.PartialSemantic; } export function initializeWebSystem(args: string[]): StartInput { createWebSystem(args); - const serverMode = parseServerMode(); + 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, - serverMode, + // Webserver defaults to partial semantic mode + serverMode: serverMode ?? LanguageServiceMode.PartialSemantic, unknownServerMode, startSession: startWebSession }; @@ -52,7 +52,7 @@ namespace ts.server { function createLogger() { const cmdLineVerbosity = getLogLevel(findArgument("--logVerbosity")); - return typeof cmdLineVerbosity === "undefined" ? nullLogger : new MainProcessLogger(cmdLineVerbosity, { writeMessage }); + return cmdLineVerbosity !== undefined ? new MainProcessLogger(cmdLineVerbosity, { writeMessage }) : nullLogger; } function writeMessage(s: any) { @@ -89,7 +89,7 @@ namespace ts.server { const now = self.performance.now(performance) * 1e-3; let seconds = Math.floor(now); let nanoseconds = Math.floor((now % 1) * 1e9); - if (typeof previous === "number") { + if (previous) { seconds = seconds - previous[0]; nanoseconds = nanoseconds - previous[1]; if (nanoseconds < 0) { @@ -101,7 +101,6 @@ namespace ts.server { } function startWebSession(options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken) { - debugger; class WorkerSession extends server.WorkerSession { constructor() { super(sys as ServerHost, { writeMessage }, options, logger, cancellationToken, hrtime); diff --git a/src/webServer/webServer.ts b/src/webServer/webServer.ts index a753afc103d72..df149ed3460eb 100644 --- a/src/webServer/webServer.ts +++ b/src/webServer/webServer.ts @@ -1,83 +1,106 @@ /*@internal*/ namespace ts.server { - export interface HostWithPostMessage { + export interface HostWithWriteMessage { writeMessage(s: any): void; } - export interface WebHost extends HostWithPostMessage { + export interface WebHost extends HostWithWriteMessage { readFile(path: string): string | undefined; fileExists(path: string): boolean; } - export type MessageLogLevel = "info" | "perf" | "error"; - export interface LoggingMessage { - readonly type: "log"; - readonly level: MessageLogLevel; - readonly body: string - } - export class MainProcessLogger implements Logger { - private currentGroupCount = 0; + export class BaseLogger implements Logger { private seq = 0; - close = noop; - - constructor(private readonly level: LogLevel, private host: HostWithPostMessage) { + private inGroup = false; + private firstInGroup = true; + constructor(protected readonly level: LogLevel) { } - - hasLevel(level: LogLevel): boolean { - return this.level >= level; + static padStringRight(str: string, padding: string) { + return (str + padding).slice(0, padding.length); } - - loggingEnabled(): boolean { - return true; + close() { } - - perftrc(s: string): void { + getLogFileName(): string | undefined { + return undefined; + } + perftrc(s: string) { this.msg(s, Msg.Perf); } - - info(s: string): void { + info(s: string) { this.msg(s, Msg.Info); } - err(s: string) { this.msg(s, Msg.Err); } - - startGroup(): void { - ++this.currentGroupCount; + startGroup() { + this.inGroup = true; + this.firstInGroup = true; } - - endGroup(): void { - this.currentGroupCount = Math.max(0, this.currentGroupCount - 1); + endGroup() { + this.inGroup = false; } - - msg(s: string, type: Msg = Msg.Err): void { - s = `${type} ${this.seq.toString()} [${nowString()}] ${s}`; - + loggingEnabled() { + return true; + } + hasLevel(level: LogLevel) { + return this.loggingEnabled() && this.level >= level; + } + msg(s: string, type: Msg = Msg.Err) { switch (type) { case Msg.Info: - this.write("info", s); + perfLogger.logInfoEvent(s); break; - case Msg.Perf: - this.write("perf", s); + perfLogger.logPerfEvent(s); break; - - case Msg.Err: - default: - this.write("error", s); + default: // Msg.Err + perfLogger.logErrEvent(s); break; } - if (this.currentGroupCount === 0) { + 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++; } } - - getLogFileName(): string | undefined { - return undefined; + protected canWrite() { + return true; } + protected write(_s: string, _type: Msg) { + } + } - private write(level: MessageLogLevel, body: string) { + 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, @@ -156,13 +179,13 @@ namespace ts.server { serverMode: SessionOptions["serverMode"]; } export class WorkerSession extends Session<{}> { - constructor(host: ServerHost, private webHost: HostWithPostMessage, options: StartSessionOptions, logger: Logger, cancellationToken: ServerCancellationToken, hrtime: SessionOptions["hrtime"]) { + 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 override in this class so not needed + byteLength: notImplemented, // Formats the message text in send of Session which is overriden in this class so not needed hrtime, logger, canUseEvents: false, diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index d8bf7c9e8f533..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,9 +10067,9 @@ declare namespace ts.server { private resetCurrentRequest; executeWithRequestId(requestId: number, f: () => T): T; executeCommand(request: protocol.Request): HandlerResponse; - onMessage(message: MessageType): void; - protected parseMessage(message: MessageType): protocol.Request; - protected toStringMessage(message: MessageType): string; + onMessage(message: TMessage): void; + protected parseMessage(message: TMessage): protocol.Request; + protected toStringMessage(message: TMessage): string; private getFormatOptions; private getPreferences; private getHostFormatOptions;