9 November 2022
Simple LSP Server Wrapper for Node.js
Note: I never got LSP fully working in my editor and this is one of those “don’t roll your own” kind of things (but I couldn’t find a quickly-usable existing library). No promises that this works! The main value here is probably the response buffer handling logic, which took a bit to get working.
A quick-and-dirty wrapper around an LSP server using stdio
for communication. This class spawns the process and restarts it after two seconds if it exits unexpectedly. Once running, call request(method, params)
/notify(method, params)
and listen to the notification
event to interact with the server.
let Evented = require("../../utils/Evented");
let lid = require("../../utils/lid");
let spawn = require("../../utils/spawn");
let sleep = require("../../utils/sleep");
let promiseWithMethods = require("../../utils/promiseWithMethods");
let config = require("./config");
class LspServer extends Evented {
constructor(app, langCode, initializeParams) {
super();
this.app = app;
this.langCode = langCode;
this.initializeParams = initializeParams;
this.requestPromises = {};
this.ready = false;
this.responseBuffer = Buffer.alloc(0);
}
async start() {
let {command, setInitializeParams} = config.perLang[this.langCode](this.app);
let [cmd, ...args] = command;
this.process = await spawn(cmd, args);
this.process.stdout.on("data", this.onData.bind(this));
this.process.stderr.on("data", this.onError.bind(this));
this.process.on("exit", this.onExit.bind(this));
let initializeParams = {
processId: process.pid,
...this.initializeParams,
};
setInitializeParams(initializeParams);
let {capabilities} = await this.request("initialize", initializeParams);
this.serverCapabilities = capabilities;
this.ready = true;
this.fire("start");
}
request(method, params) {
if (!this.ready) {
throw "Server not ready";
}
let id = lid();
let json = JSON.stringify({
id,
jsonrpc: "2.0",
method,
params,
});
let message = "Content-Length: " + json.length + "\r\n\r\n" + json;
this.process.stdin.write(message);
let promise = promiseWithMethods();
this.requestPromises[id] = promise;
setTimeout(() => {
delete this.requestPromises[id];
promise.reject({
error: "Request timed out",
method,
params,
});
}, config.requestTimeout);
return promise;
}
notify(method, params) {
if (!this.ready) {
throw "Server not ready";
}
let json = JSON.stringify({
jsonrpc: "2.0",
method,
params,
});
let message = "Content-Length: " + json.length + "\r\n\r\n" + json;
this.process.stdin.write(message);
}
close() {
this.closed = true;
this.ready = false;
this.process.kill();
}
onData(data) {
let {responseBuffer} = this;
this.responseBuffer = Buffer.alloc(responseBuffer.length + data.length);
this.responseBuffer.set(responseBuffer);
this.responseBuffer.set(data, responseBuffer.length);
this.checkResponseBuffer();
}
checkResponseBuffer() {
let split = -1;
for (let i = 0; i < this.responseBuffer.length; i++) {
if (this.responseBuffer.subarray(i, i + 4).toString() === "\r\n\r\n") {
split = i + 4;
break;
}
}
if (split === -1) {
return;
}
let headers = this.responseBuffer.subarray(0, split).toString();
let length = Number(headers.match(/Content-Length: (d+)/)[1]);
let rest = this.responseBuffer.subarray(split, split + length);
if (rest.length < length) {
return;
}
this.responseBuffer = Buffer.from(this.responseBuffer.subarray(split + length));
let message = JSON.parse(rest.toString());
if (message.id) {
let {id, error, result} = message;
if (error) {
this.requestPromises[id].reject(error);
} else {
this.requestPromises[id].resolve(result);
}
delete this.requestPromises[id];
} else {
let {method, params} = message;
this.fire("notification", {method, params});
}
if (this.responseBuffer.length > 0) {
this.checkResponseBuffer();
}
}
onError(data) {
console.error(data.toString());
this.fire("error", data.toString());
}
async onExit() {
if (this.closed) {
return;
}
this.ready = false;
await sleep(2000);
this.start();
}
}
module.exports = LspServer;