So, you want to set up a Monaco editor with a language server

__4f1641

Георгий Кожевников

Posted on October 19, 2024

So, you want to set up a Monaco editor with a language server

Motivation

Currently it's not simple to connect an LSP language server to a custom editor (not Neovim and VSCode), the docs are usually sparse and there is lack of simple and documented projects that implement that.
I faced this, and decided to dive down and document my way, so I hope this post helps anyone.

In this post I will cover:

  • Basics of monaco-editor
  • Using monaco-editor with several editors
  • Using monaco-vscode-api package and setting up the basic language features
  • Adding monaco-languageclient and Python LSP

I tried to document all the sources along the way, so you can go and learn more where needed.

Simple monaco-editor

Let's start with the most simple Monaco setup. I will use vanilla TS with Vite and Bun as package manager, so I hope it will be simple to extrapolate into different frameworks.
You can also find similar example written with React in the official repo.

Feel free to skip this part if you already know about basic monaco-editor setup.

Initial project

First, let's init a Vite project as described in Bun docs and install monaco-editor:

bun create vite my-monaco-editor
cd my-monaco-editor
bun install
bun add monaco-editor
Enter fullscreen mode Exit fullscreen mode

Then, all we need is a simple html page with one div -- it will be the editor.

<!-- index.html -->
<html>
  <body>
    <div id="editor"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Add little style:

/* style.css */
body {
  background-color: #242424;
}

#editor {
  margin: 10vh auto;
  width: 720px;
  height: 20vh;
}
Enter fullscreen mode Exit fullscreen mode

And now we can create a Monaco editor instance, which will automatically fill the div with interactive editor:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

monaco.editor.create(document.getElementById('editor')!, {
    value: "Hello world!",
});
Enter fullscreen mode Exit fullscreen mode

Voila! Working Monaco editor is here.

Basic Monaco setup

Adding workers

If you look into the DevTools console, you may notice a warning:

Could not create web worker(s). Falling back to loading web worker code in main thread, which might cause UI freezes. Please see https://github.com/microsoft/monaco-editor#faq

That's because Monaco editor usually separates text processing and UI interaction into different processes, so they work asynchronously without interfering with each other.

One can do it manually, like this:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
    getWorker(_workerId: any, _label: string) {
        return new editorWorker();
    }
};
monaco.editor.create(document.getElementById('editor')!, {
    value: "Hello world!",
});
Enter fullscreen mode Exit fullscreen mode

We can provide workers for text processing using window.MonacoEnvironment attribute. The getWorker function receives label - which is the name of a worker required by editor. Since currently we do not use any languages, the default editorWorker will do.

Now everything works and there is no any warnings in the console.

Adding languages

Now, since Monaco is code editor, let's add some coding language processing. This can be done by adding language attribute to the options object in monaco.editor.create call:

// main.ts
monaco.editor.create(document.getElementById('editor')!, {
    value: "console.log('Hello world!');",
    language: "typescript"
});
Enter fullscreen mode Exit fullscreen mode

However, we did not provide the corresponding worker yet. Hopefully, Monaco provides a built-in worker for typescript and javascript:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';

window.MonacoEnvironment = {
    getWorker(_workerId: any, label: string) {
        if (label === 'typescript' || label === 'javascript') {
            return new tsWorker();
        }
        return new editorWorker();
    }
};
monaco.editor.create(document.getElementById('editor')!, {
    value: "console.log('Hello world!');",
    language: "typescript"
});
Enter fullscreen mode Exit fullscreen mode

We need first to check, which worker is requested, and then return the corresponding one. When we create an editor with given language, the Monaco calls getWorker, providing language as label parameter.

However, this is true only for some subset of languages, which are built into Monaco by default:

  • json
  • css
  • html
  • typescript
  • javascript

For other languages Monaco provides less features out of the box and uses the default editorWorker. So if your editor is only for Python, you can leave just editorWorker in the getWorkter function, but still, provide language: "python" when creating an editor:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
    getWorker(_workerId: any, _label: string) {
        return new editorWorker();
    }
};
monaco.editor.create(document.getElementById('editor')!, {
    value: "print('Hello world!')",
    language: "python"
});
Enter fullscreen mode Exit fullscreen mode

Adding more editors

Imagine that you need more than one editor, e.g. for different files. The most straightforward path is to just create another div and call monaco.editor.create one more time:

<!-- index.html -->
<html>
  <body>
    <div id="editor1"></div>
    <div id="editor2"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Enter fullscreen mode Exit fullscreen mode
/* style.css */
body {
  background-color: #242424;
}

#editor1, #editor2 {
  margin: 10vh auto;
  width: 720px;
  height: 20vh;
}

Enter fullscreen mode Exit fullscreen mode
// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
    getWorker(_workerId: any, label: string) {
        return new editorWorker();
    }
};
monaco.editor.create(document.getElementById('editor1')!, {
    value: "print('Hello world 1!')",
    language: "python"
});

monaco.editor.create(document.getElementById('editor2')!, {
    value: "print('Hello world 2!')",
    language: "python"
});
Enter fullscreen mode Exit fullscreen mode

This will work, but not ideally -- Monaco will try to autocomplete variable names from different editor:
Monaco autocomplete from different editor
It's easy to fix, just add wordBasedSuggestions field set to currentDocument:

monaco.editor.create(document.getElementById('editor2')!, {
    value: "print('Hello world 2!')",
    language: "python",
    wordBasedSuggestions: 'currentDocument'
});
Enter fullscreen mode Exit fullscreen mode

That was the basic possible setup of monaco-editor. If you are using it for TypeScript/CSS/HTML, that may be enough because of the built-in workers. However, if you need it for Python or any other language, you may need to integrate a custom LSP to support advanced features like IntelliSense, default keyword autocompletion, code navigation, linting, etc.

VSCode-API

Let's try to build a Monaco editor with full LSP functionality for Python.

Unfortunately, support for LSP is not built in natively to Monaco, so you can't just do something like:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import languageClient from 'monaco-lsp' // doesn't exist

window.MonacoEnvironment = {
    getWorker(_workerId: any, _label: string) {
        return new editorWorker();
    }
};

const lsp = new languageClient( // doesn't exist
    serverUri="ws://localhost:5007", 
    rootUri="file:///", 
    languageId="python"
)
monaco.editor.create(document.getElementById('editor')!, {
    value: "print('Hello world!')",
    language: "python",
    lsp: lsp // doesn't work
});

Enter fullscreen mode Exit fullscreen mode

However, most of the language servers work with VSCode out of the box via extensions. And since VSCode is built around Monaco, it's possible to integrate VSCode API (e.g. extensions and other stuff) into Monaco. Including LSP support.

Package monaco-vscode-api does exactly that. But moreover, it in some way redesigns the monaco-editor, making it much more modular (but also more complex).

The documentation on it is not very good (some info in the README and examples in issues and the demo). So, mostly I've figured it all out via trial and error and 3 issues focused on struggle of implementing the basic functionality:

It changes the code drastically, but some main concepts are the same. Let's start again with a minimal example to demonstrate that.

New beginning

Let's create a new project in the same way as before, but instead of monaco-editor we will install monaco-vscode-api packages

# before proceeding make sure you are not in an existing project
bun create vite my-monaco-api-editor
cd my-monaco-api-editor
bun install

bun add vscode@npm:@codingame/monaco-vscode-api
bun add monaco-editor@npm:@codingame/monaco-vscode-editor-api
bun add -D @types/vscode
Enter fullscreen mode Exit fullscreen mode

These packages names may look weird, because they use aliases to be used as enhanced drop-in replacement for vscode and monaco-editor packages. They change their functionality to allow the usage of VSCode services and extensions in Monaco, but provide the same interface as the original packages.

Then, again, all we need is a simple html page with one div -- it will be the editor.

<!-- index.html -->
<html>
  <body>
    <div id="editor"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Add little style:

/* style.css */
body {
  background-color: #242424;
}

#editor {
  margin: 10vh auto;
  width: 720px;
  height: 20vh;
}
Enter fullscreen mode Exit fullscreen mode

As for main.ts, we can start with totally same example, since we've just used drop-in replacement:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

window.MonacoEnvironment = {
  getWorker: function (_moduleId, _label) {
    return new editorWorker();
  }
}
monaco.editor.create(document.getElementById('editor')!, {
    value: "Hello world!",
});

Enter fullscreen mode Exit fullscreen mode

In the monaco-vscode-api repo's issues and demo project you will meet the following variant of adding workers:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
    TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' })
    })
}
window.MonacoEnvironment = {
  getWorker: function (_workerId, label) {
    const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
    throw new Error(`Worker ${label} not found`)
  }
}

monaco.editor.create(document.getElementById('editor')!, {
    value: "Hello world!",
});

Enter fullscreen mode Exit fullscreen mode

It's basically the same, but more strictly checks if the required worker is implemented. Also, the worker initialization is a little different but still functionally same - TextEditorWorker is label for the default editorWorker from the previous examples.

There are some nuances to account for in your bundler. They are described in the Troubleshooting section of the repo. Since I'm using Vite here, I'll provide details for Vite users below.

For Vite users
It uses import.meta.url base which doesn't work well with Vite out of the box. So if you are using Vite, add this to your vite.config.ts (create it if not yet):
 import type { UserConfig } from 'vite'
 import importMetaUrlPlugin from '@codingame/esbuild-import-meta-url-plugin'

 export default {
    optimizeDeps: {
        esbuildOptions: {
          plugins: [importMetaUrlPlugin]
        }
      }
} satisfies UserConfig
Enter fullscreen mode Exit fullscreen mode

And install the corresponding package

bun add @codingame/esbuild-import-meta-url-plugin
Enter fullscreen mode Exit fullscreen mode

Further, I will use the latter approach to worker initialization so the reader is more used to the notation usually met in the repo. Also, we will know if any worker is not added properly via error in the console.

Adding language

So, we've built a basic text editor using new monaco-vscode-api as a drop-in replacement for monaco-editor. Let's try to add Python highlighting. Previously, it was made by adding language attribute to the monaco.editor.create options object.

However, if we add language, nothing changes and even the highlighting is absent:

import './style.css'
import * as monaco from 'monaco-editor';

import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
    TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' })
    })
}
window.MonacoEnvironment = {
  getWorker: function (_workerId, label) {
    const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
    throw new Error(`Worker ${label} not found`)
  }
}

monaco.editor.create(document.getElementById('editor')!, {
    value: "print('Hello world!')",
    language: "python"
});

Enter fullscreen mode Exit fullscreen mode

Monaco with no highlighting
That's because the default workers which supplied most of the functions doesn't work in monaco-vscode-api the same way they did in monaco-editor. Now, the most of the editor functionality is based on VSCode services - components which provide specific functions, even the basic ones. Here is a large list of services, supported by monaco-vscode-api. And to make this functionality work, one need to add appropriate services manually.

E.g. to support highlighting, now we need to add the following services:

  • Textmate@codingame/monaco-vscode-textmate-service-override
    • Allows to use textmate grammars to tokenize languages for highlighting.
  • Themes@codingame/monaco-vscode-theme-service-override
  • Languages@codingame/monaco-vscode-languages-service-override
    • Allows to account for the language field and setup textmate grammars for highlighting and other language specific functions. And import the corresponding language and theme extensions (see below).

Adding services is simple, install the corresponding package from the list and pass the ...get*ServiceOverride() into initialize function from vscode/services before creating editors.
Let's try this.

Installing packages for services and extensions:

# splitted into several commands for readibility
bun add @codingame/monaco-vscode-textmate-service-override 
bun add @codingame/monaco-vscode-theme-service-override 
bun add @codingame/monaco-vscode-languages-service-override
bun add @codingame/monaco-vscode-python-default-extension
bun add @codingame/monaco-vscode-theme-defaults-default-extension
Enter fullscreen mode Exit fullscreen mode

Adding services to the editor:

// main.ts
import './style.css'
import * as monaco from 'monaco-editor';

// importing installed services
import { initialize } from 'vscode/services'
import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
import getThemeServiceOverride from "@codingame/monaco-vscode-theme-service-override";
import getTextMateServiceOverride from "@codingame/monaco-vscode-textmate-service-override";

// adding worker
export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
    TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' })
    })
}
window.MonacoEnvironment = {
  getWorker: function (_workerId, label) {
    const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
    throw new Error(`Worker ${label} not found`)
  }
}

// adding services
await initialize({
    ...getTextMateServiceOverride(),
    ...getThemeServiceOverride(),
    ...getLanguagesServiceOverride(),
});

monaco.editor.create(document.getElementById('editor')!, {
    value: "print('Hello world!')",
    language: "python"
});
Enter fullscreen mode Exit fullscreen mode

This will work without any warnings or errors, but the highlighting is not there yet. To add it, we need to finally integrate
1) Python language default extension which will provide Python grammar;
2) TextMate worker which will tokenize the code based on the grammar;
3) Theme, so different keywords can have unique color.

Python language and theme are both VSCode extensions. Installation of extensions described in detail in README.md. Fortunately, for default VSCode extensions like the ones we need, there are prebuilt packages by the repo's authors:

  • @codingame/monaco-vscode-python-default-extension for Python
  • @codingame/monaco-vscode-theme-defaults-default-extension for default VSCode themes Adding them to the project is as simple as installing the packages and adding the corresponding imports to the beginning of the main.ts.
// main.ts
import '@codingame/monaco-vscode-python-default-extension';
import "@codingame/monaco-vscode-theme-defaults-default-extension";

... // rest of the code
Enter fullscreen mode Exit fullscreen mode

To integrate the TextMate worker, we need to add it to the workerLoaders map:

// main.ts
...

const workerLoaders: Partial<Record<string, WorkerLoader>> = {
    TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' }),
    TextMateWorker: () => new Worker(new URL('monaco-editor/esm/vs/language/textmate/textmate.worker.js', import.meta.url), { type: 'module' })
}
...
Enter fullscreen mode Exit fullscreen mode

How to find necessary services and/or extensions?
You can find a full list of services and extensions here: https://www.npmjs.com/search?q=%40codingame%2Fmonaco-vscode-*-default-extension
There is no full documentation so, to find out what you need you usually look into issues/demo/other projects using monaco-vscode-api, and copy that, or intuitively add services/extensions based on their name until it is not working. At least, I've not found better way yet.

And voila, the final code with Python highlighting support:

import '@codingame/monaco-vscode-python-default-extension';
import "@codingame/monaco-vscode-theme-defaults-default-extension";

import './style.css'
import * as monaco from 'monaco-editor';
import { initialize } from 'vscode/services'


import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
import getThemeServiceOverride from "@codingame/monaco-vscode-theme-service-override";
import getTextMateServiceOverride from "@codingame/monaco-vscode-textmate-service-override";

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
    TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' }),
    TextMateWorker: () => new Worker(new URL('monaco-editor/esm/vs/language/textmate/textmate.worker.js', import.meta.url), { type: 'module' })
}

window.MonacoEnvironment = {
  getWorker: function (_moduleId, label) {
    console.log('getWorker', _moduleId, label);
    const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
    throw new Error(`Worker ${label} not found`)
  }
}

await initialize({
    ...getTextMateServiceOverride(),
    ...getThemeServiceOverride(),
    ...getLanguagesServiceOverride(),
});

monaco.editor.create(document.getElementById('editor')!, {
    value: "import numpy as np\nprint('Hello world!')",
    language: 'python'
});
Enter fullscreen mode Exit fullscreen mode

Note: afaik, to turn the highlighting on you may need to manually edit your code (e.g. add and remove a whitespace), so the TextMate worker starts to work. I may update this later if I find a robust solution.

Introducing language server

Now, let's finally add language server. For this one, we will need to use a cousin package of monaco-vscode-api called monaco-languageclient which actively utilizes the former.

We also will need a language server itself.

Note on LSP servers
Usually when using VSCode, you just select a language and install the corresponding language server extension from Marketplace, e.g. Pyright of Ruff for Python. Under the hood, most of these VSCode language server extensions utilize vscode-languageclient api. The API allows to launch LSP server in several ways, e.g. as a node module running in runtime provided by VSCode itself, or as a child process via runnable command.
You can take a look at the Pylyzer Python LSP extension to see an example of usage of the API.

Note that in order to use it, you need a runtime that has access to your files.

There is a possibility to add VSCode server to your Monaco project and use it to launch language servers, however it adds additional complexity and dependency.
In this guide I will avoid it.

There are other ways to run Language Server, e.g. one can create a new language server or a wrapper for existing one with pygls, to run it as Python process providing websocket server. Here is a great guide with introduction to language servers and monaco language client. Other similar option but for Rust is tower-lsp.




Let's go with the most simple way for Python -- use python-lsp-server, which provides websocket LSP server with all bells and whistles out of the box.

The following example will be based on a bare client example implementation from the languageclient repo.

To proceed, we will need to install two additional packages:

bun add vscode-ws-jsonrpc
bun add monaco-languageclient
Enter fullscreen mode Exit fullscreen mode

Then, let's create a file lsp-client.ts. Here we will write initialization functions for the LSP client. There we will handle WebSocket connection with the server.

// lsp-client.ts
import { WebSocketMessageReader } from 'vscode-ws-jsonrpc';
import { CloseAction, ErrorAction, MessageTransports } from 'vscode-languageclient/browser.js';
import { WebSocketMessageWriter } from 'vscode-ws-jsonrpc';
import { toSocket } from 'vscode-ws-jsonrpc';
import { MonacoLanguageClient } from 'monaco-languageclient';

export const initWebSocketAndStartClient = (url: string): WebSocket => {
    const webSocket = new WebSocket(url);
    webSocket.onopen = () => {
        // creating messageTransport
        const socket = toSocket(webSocket);
        const reader = new WebSocketMessageReader(socket);
        const writer = new WebSocketMessageWriter(socket);
        // creating language client
        const languageClient = createLanguageClient({
            reader,
            writer
        });
        languageClient.start();
        reader.onClose(() => languageClient.stop());
    };
    return webSocket;
};
const createLanguageClient = (messageTransports: MessageTransports): MonacoLanguageClient => {
    return new MonacoLanguageClient({
        name: 'Sample Language Client',
        clientOptions: {
            // use a language id as a document selector
            documentSelector: ['python'],
            // disable the default error handler
            errorHandler: {
                error: () => ({ action: ErrorAction.Continue }),
                closed: () => ({ action: CloseAction.DoNotRestart })
            }
        },
        // create a language client connection from the JSON RPC connection on demand
        connectionProvider: {
            get: async (_encoding: string) => messageTransports
        }
    });
};
Enter fullscreen mode Exit fullscreen mode

The key concept here is messageTransports parameter in createLanguageClient function. It is a pair of initialized websocket reader and writer that allow to communicate with the server.

Now, all we need to make it work is to run the initWebSocketAndStartClient function from main.ts providing url and port of the websocket language server:

import '@codingame/monaco-vscode-python-default-extension';
import "@codingame/monaco-vscode-theme-defaults-default-extension";

import './style.css'
import * as monaco from 'monaco-editor';
import { initialize } from 'vscode/services'

// we need to import this so monaco-languageclient can use vscode-api
import "vscode/localExtensionHost";
import { initWebSocketAndStartClient } from 'lsp-client'

// everything else is the same except the last line
import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
import getThemeServiceOverride from "@codingame/monaco-vscode-theme-service-override";
import getTextMateServiceOverride from "@codingame/monaco-vscode-textmate-service-override";

export type WorkerLoader = () => Worker;
const workerLoaders: Partial<Record<string, WorkerLoader>> = {
    TextEditorWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' }),
    TextMateWorker: () => new Worker(new URL('monaco-editor/esm/vs/language/textmate/textmate.worker.js', import.meta.url), { type: 'module' })
}

window.MonacoEnvironment = {
  getWorker: function (_moduleId, label) {
    console.log('getWorker', _moduleId, label);
    const workerFactory = workerLoaders[label]
    if (workerFactory != null) {
      return workerFactory()
    }
    throw new Error(`Worker ${label} not found`)
  }
}

await initialize({
    ...getTextMateServiceOverride(),
    ...getThemeServiceOverride(),
    ...getLanguagesServiceOverride(),
});

monaco.editor.create(document.getElementById('editor')!, {
    value: "import numpy as np\nprint('Hello world!')",
    language: 'python'
});

// start websocket lsp client on port 5007 
// (you can choose any port, just make sure the server uses the same)
initWebSocketAndStartClient("ws://localhost:5007/")
Enter fullscreen mode Exit fullscreen mode

Now, just install and run python-lsp-server on the port you selected:

pip install python-lsp-server
pylsp --ws --port 5007
Enter fullscreen mode Exit fullscreen mode

And here we go:

Monaco with LSP

Where to go next

  • You can reduce the amount of boilerplate (e.g. adding services for basic functionality like themes, highlighting, etc.) by using monaco-editor-wrapper
  • You can dive deeper into the concept of models in order to better control LSP features between files in your project
  • You can try to set up some nodejs-based language server like pyright or basedpyright.
  • Look at the examples in monaco-languageclient to learn more.
💖 💪 🙅 🚩
__4f1641
Георгий Кожевников

Posted on October 19, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related