typescript
Version:
TypeScript is a language for application scale JavaScript development
398 lines • 17 kB
JavaScript
import { createMessageConnection, RequestType, SocketMessageReader, SocketMessageWriter, StreamMessageReader, StreamMessageWriter, } from "../../../vendor/vscode-jsonrpc/lib/node/main.js";
import { configureFileSystemCallbacks, encodeFileSystemCallbackResult, } from "../fsCallbacks.js";
import { getAPIProcessArgs, isSpawnOptions, resolveExePath, } from "../options.js";
import { combineTimingInfo, disabledServerTimingInfo, disabledTimingInfo, TimingCollector, } from "../timing.js";
import { TimingDump } from "../timingDump.js";
/**
* Client handles communication with the TypeScript API server
* over STDIO (spawned process) or a Unix domain socket using JSON-RPC.
*/
export class Client {
socket;
process;
connection;
options;
connected = false;
closed = false;
connecting;
timing;
timingDump;
closing;
batchedRequests = [];
nextBatch;
constructor(options) {
this.timingDump = TimingDump.fromEnvironment("async", isSpawnOptions(options) ? "spawn" : "socket");
if (this.timingDump || isSpawnOptions(options) && options.collectTiming) {
this.timing = new TimingCollector();
}
if (this.timingDump && isSpawnOptions(options)) {
options = { ...options, collectTiming: true };
}
this.options = options;
if (isSpawnOptions(options)) {
configureFileSystemCallbacks(options.fs);
}
}
connect() {
if (this.closed)
return Promise.reject(new Error("Client is closed"));
if (this.connected)
return Promise.resolve();
return this.connecting ??= this.connectWorker().finally(() => {
this.connecting = undefined;
});
}
async connectWorker() {
if (isSpawnOptions(this.options)) {
await this.connectViaSpawn(this.options);
}
else {
await this.connectViaSocket(this.options);
}
}
async connectViaSpawn(options) {
const { spawn } = await import("node:child_process");
return new Promise((resolve, reject) => {
const args = getAPIProcessArgs(options, true);
const fsConfiguration = configureFileSystemCallbacks(options.fs);
if (fsConfiguration.arguments.length > 0) {
args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`);
}
this.process = spawn(resolveExePath(options), args, {
stdio: ["pipe", "pipe", "inherit"],
});
this.process.once("error", error => {
reject(new Error(`Failed to start tsgo process: ${error.message}`));
});
this.process.once("spawn", () => {
this.connected = true;
resolve();
});
const reader = new StreamMessageReader(this.process.stdout);
const writer = new StreamMessageWriter(this.process.stdin);
this.connection = createMessageConnection(reader, writer);
this.registerFSCallbacks(this.connection, options.fs, fsConfiguration);
this.connection.listen();
});
}
async connectViaSocket(options) {
const { createConnection } = await import("node:net");
return new Promise((resolve, reject) => {
this.socket = createConnection(options.pipe, () => {
const reader = new SocketMessageReader(this.socket);
const writer = new SocketMessageWriter(this.socket);
this.connection = createMessageConnection(reader, writer);
this.connection.listen();
this.connected = true;
resolve();
});
this.socket.once("error", error => {
reject(new Error(`Socket error: ${error.message}`));
});
});
}
registerFSCallbacks(connection, fs, configuration) {
if (!fs)
return;
for (const name of configuration.callbackNames) {
switch (name) {
case "readFile": {
const callback = fs.readFile;
if (typeof callback !== "function")
throw new Error("Invalid readFile callback configuration");
connection.onRequest(new RequestType(name), fileName => {
return encodeFileSystemCallbackResult(name, callback(fileName));
});
break;
}
case "fileExists": {
const callback = fs.fileExists;
if (typeof callback !== "function")
throw new Error("Invalid fileExists callback configuration");
connection.onRequest(new RequestType(name), fileName => {
return encodeFileSystemCallbackResult(name, callback(fileName));
});
break;
}
case "directoryExists": {
const callback = fs.directoryExists;
if (typeof callback !== "function")
throw new Error("Invalid directoryExists callback configuration");
connection.onRequest(new RequestType(name), directoryName => {
return encodeFileSystemCallbackResult(name, callback(directoryName));
});
break;
}
case "getAccessibleEntries": {
const callback = fs.getAccessibleEntries;
if (typeof callback !== "function")
throw new Error("Invalid getAccessibleEntries callback configuration");
connection.onRequest(new RequestType(name), directoryName => {
return encodeFileSystemCallbackResult(name, callback(directoryName));
});
break;
}
case "realpath": {
const callback = fs.realpath;
if (typeof callback !== "function")
throw new Error("Invalid realpath callback configuration");
connection.onRequest(new RequestType(name), path => {
return encodeFileSystemCallbackResult(name, callback(path));
});
break;
}
case "stat": {
const callback = fs.stat;
if (typeof callback !== "function")
throw new Error("Invalid stat callback configuration");
connection.onRequest(new RequestType(name), path => {
return encodeFileSystemCallbackResult(name, callback(path));
});
break;
}
case "writeFile": {
const callback = fs.writeFile;
if (typeof callback !== "function")
throw new Error("Invalid writeFile callback configuration");
connection.onRequest(new RequestType(name), arg => {
return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data));
});
break;
}
case "removeFile": {
const callback = fs.removeFile;
if (typeof callback !== "function")
throw new Error("Invalid removeFile callback configuration");
connection.onRequest(new RequestType(name), path => {
return encodeFileSystemCallbackResult(name, callback(path));
});
break;
}
}
}
}
async sendRequestWithTiming(requestType, params) {
if (!this.connection) {
throw new Error("Connection not established");
}
if (!this.timing) {
return this.connection.sendRequest(requestType, params);
}
// Round-trip latency is measured here; byte counts approximate the wire
// payload via the serialized JSON. Server-side processing time is not
// carried on the response; it is retrieved separately (via a
// getServerTiming request) and folded in by getTimingInfo().
const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8");
const start = performance.now();
const result = await this.connection.sendRequest(requestType, params);
const roundTripMs = performance.now() - start;
this.timing.record({
method: requestType.method,
roundTripMs,
bytesSent,
bytesReceived: result === undefined || result === null
? 0
: Buffer.byteLength(JSON.stringify(result), "utf-8"),
});
return result;
}
registerCallback(name, callback) {
if (!this.connection) {
throw new Error("Connection not established");
}
const disposable = this.connection.onRequest(new RequestType(name), callback);
return () => disposable.dispose();
}
async doBatch() {
this.nextBatch = undefined;
if (!this.batchedRequests.length)
return;
const requests = this.batchedRequests;
this.batchedRequests = [];
try {
if (!this.connected) {
await this.connect();
}
if (!this.connection) {
throw new Error("Connection not established");
}
if (requests.length === 1) {
// send single queued requests directly instead of as a batched request
const requestType = new RequestType(requests[0].method);
const response = await this.sendRequestWithTiming(requestType, requests[0].params);
requests[0].resolve(response);
return;
}
const requestType = new RequestType("batchRequests");
const params = { requests: requests.map(request => ({ method: request.method, params: request.params })) };
if (this.options.maxResponseBytesPerPage !== undefined) {
params.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage;
}
const response = await this.sendRequestWithTiming(requestType, params);
let responses = response.responses;
let continuationToken = response.continuationToken;
while (continuationToken) {
const pageParams = {
requests: [],
continuationToken,
};
if (this.options.maxResponseBytesPerPage !== undefined) {
pageParams.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage;
}
const page = await this.sendRequestWithTiming(requestType, pageParams);
responses = responses.concat(page.responses);
continuationToken = page.continuationToken;
}
for (let i = 0; i < requests.length; i++) {
const { resolve, reject } = requests[i];
const item = responses[i];
if (item.error !== undefined) {
reject(new Error(item.error));
}
else {
resolve(item.result);
}
}
}
catch (error) {
for (const { reject } of requests)
reject(error);
}
}
scheduleImmediateBatch() {
if (this.nextBatch)
return;
this.nextBatch = setImmediate(this.doBatch.bind(this));
}
batchContext() {
if (this.nextBatch === "manual") {
throw new Error("Already in a manual batch context");
}
if (this.nextBatch) {
clearImmediate(this.nextBatch);
this.doBatch(); // empty the queue before entering a manual batch context
}
this.nextBatch = "manual";
return {
[Symbol.dispose]: () => {
this.nextBatch = undefined;
this.scheduleImmediateBatch();
},
};
}
async apiRequest(method, params) {
if (this.closed)
throw new Error("Client is closed");
if (!this.connected) {
await this.connect();
}
if (!this.connection) {
throw new Error("Connection not established");
}
const resultPromise = new Promise((resolve, reject) => {
this.batchedRequests.push({ method, params, resolve, reject });
this.scheduleImmediateBatch();
});
return resultPromise;
}
async apiRequestBinary(method, params) {
const response = await this.apiRequest(method, params);
if (!response)
return undefined;
const buffer = Buffer.from(response.data, "base64");
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
}
/**
* Returns the timing collector that per-node materialization is reported
* into, or undefined when timing collection is disabled. The returned
* collector is the same one folded into {@link getTimingInfo}, so
* materialization totals surface alongside request timings.
*/
getTimingCollector() {
return this.timing;
}
/**
* Returns a combined timing snapshot: client-measured round-trip and byte
* counts folded together with the server's own per-request processing time
* (fetched via a getServerTiming request) and estimated transport overhead.
*/
async getTimingInfo() {
if (!this.timing) {
return disabledTimingInfo();
}
const local = this.timing.getInfo();
// No requests have been sent yet: nothing to fetch from the server.
if (!this.connected || !this.connection) {
return local;
}
return combineTimingInfo(local, await this.fetchServerTiming());
}
async resetTimingInfo() {
if (!this.timing)
return;
this.timing.reset();
if (this.connected && this.connection) {
// Keep the server's collection in sync so combined totals stay meaningful.
const requestType = new RequestType("resetServerTiming");
await this.connection.sendRequest(requestType, undefined);
}
}
async fetchServerTiming() {
if (!this.connection) {
return disabledServerTimingInfo();
}
// Fetch the server's own timing collection via a dedicated request. This
// bypasses the client-side collector so the query does not pollute it.
const requestType = new RequestType("getServerTiming");
return this.connection.sendRequest(requestType, undefined);
}
close() {
return this.closing ??= this.closeWorker();
}
async closeWorker() {
await this.connecting?.catch(() => { }); // if connection is still in-progress, wait for it to finish before closing the connection
this.closed = true;
try {
if (this.timingDump && this.timing) {
const local = this.timing.getInfo();
let server;
let serverTimingError;
const connection = this.connection;
// Closing alone does not reject pending JSON-RPC requests.
const onClose = connection?.onClose(() => connection.dispose());
try {
if (this.connected)
server = await this.fetchServerTiming();
}
catch (error) {
serverTimingError = String(error);
}
finally {
onClose?.dispose();
}
this.timingDump.write(local, server, serverTimingError);
}
}
finally {
if (this.connection) {
this.connection.dispose();
this.connection = undefined;
}
if (this.socket) {
this.socket.destroy();
this.socket = undefined;
}
if (this.process) {
// Close stdin to unblock the server's read loop, allowing it to exit cleanly.
// The server is blocked on stdin.Read(), so just sending SIGTERM would deadlock:
// - Node won't exit while child is alive
// - Child can't process SIGTERM while blocked on read
// - Read won't error until stdin is closed
this.process.stdin?.end();
this.process = undefined;
}
this.connected = false;
}
}
}
//# sourceMappingURL=client.js.map