Teaching the Language Server Protocol to Microsoft's Monaco Editor
Through the past years we have been integrating all kinds of different JavaScipt code editors, like Ace, Orion or CodeMirror into custom web-based software tools. Since last June another very good editor, has been available: Microsoft’s Monaco editor, the editor widget that is used at the core of VSCode. Besides the very good quality and speed of the editor, the API is very close to the Language Server Protocol (LSP), which is not a surprise given that this is all developed by the same team. At TypeFox, we love both the Monaco Editor and the Language Server Protocol and use them extensively in our projects. One publicly available project is RIDE, a Data-Science IDE we developed for R-Brain. Another publicly available example is the web calc example, which is covered by Akos here. In such cases, we connect Monaco editors with language servers running remotely. So far, however, Monaco did not speak the LSP out of the box, so we had to do a lot of plumbing and shimming to make it work. This has now been generalized and published as individual npm packages under the MIT license. The Monaco Editor Language Client provides a language client that establishes communication between Monaco editors and language servers over JSON-RPC and the VSCode WebSocket JSON-RPC package enables JSON-RPC to work over web sockets. The language client package comes with an example showing how Monaco editor can be connected with JSON language server. Go ahead, check out this repository, follow an instruction to start and play with the example, and then come back for a detailed explanation.
The server side
The server side consists of two components: Express Server and JSON Language Server. We use a web application framework for node.js called Express to serve static content, as index.html and js-code, and open a web socket connection. Instead of implementing our own JSON language server we deploy VSCode JSON language service package as a language server by means of VSCode Language Server package (consult VSCode documentation to learn more about it).
Deploying the JSON language server
The JSON language server can be deployed as an external process, or within the express server’s process:
- in the first case, a child node process is spawned and JSON-RPC messages are forwarded between the web socket and the node process connections. During forwarding of the initialization request, a parent process id is set to the express server’s process id.
- in the second case, the language server works over a web socket connection directly.
Deploying an Xtext language server
What if you want to connect your Xtext language server instead of an example one? You have two options:
- stick to Express server, distribute your language server as a jar and deploy it as an external process, an approach demonstrated by Miro for a VSCode extension could be taken;
- use a Java web-container as Apache Tomcat or Eclipse Jetty, distribute your language server as a war and deploy it to the chosen container.
The client side
Bundling and loading of client code
An entry client page provides a container for the Monaco editor and loads a client side code bundled by webpack. Unfortunately, Monaco is only distributed as an AMD module and cannot be bundled by webpack. To overcome this difficulty one should ensure that Monaco code is loaded before client code.
Starting Monaco Language Client
Once Monaco code is loaded but before starting Monaco Language Client one should:
- register all necessary languages
monaco.languages.register({
id: 'json',
extensions: ['.json', '.bowerrc', '.jshintrc', '.jscsrc', '.eslintrc', '.babelrc'],
aliases: ['JSON', 'json'],
mimetypes: ['application/json'],
});
- provide language client services
Default services notify the language client about changes in the Monaco editor models, hook up the Monaco language features with the language client, e.g. a completion, hover, etc., and provide means to log messages from language servers in the console. const services = createMonacoServices();
- establish a web socket connection
// create the web socket
const url = createUrl('/sampleServer');
const webSocket = createWebSocket(url);
// listen when the web socket is opened
listen({
webSocket,
onConnection: connection => {
// create and start the language client
const languageClient = createLanguageClient(connection);
const disposable = languageClient.start();
connection.onClose(() => disposable.dispose());
}
});
Having everything wired up, one can start the actual client. In this example, we use a reconnecting web socket package to auto reopen the web socket connection if one is closed. Because of it, we have to disable the default error handling, which tries to restart the language client 5 times and start a new language client each time when a new web socket connection is opened.
function createLanguageClient(connection: MessageConnection): BaseLanguageClient {
return new BaseLanguageClient({
name: "Sample Language Client",
clientOptions: {
// use a language id as a document selector
documentSelector: ['json'],
// disable the default error handler
errorHandler: {
error: () => ErrorAction.Continue,
closed: () => CloseAction.DoNotRestart
}
},
services,
// create a language client connection from the JSON RPC connection on demand
connectionProvider: {
get: (errorHandler, closeHandler) => {
return Promise.resolve(createConnection(connection, errorHandler, closeHandler))
}
}
})
}