用 Monaco Editor 搭一个带 Python 智能提示的在线编辑器

想在网页里做一个 Python 编辑器,不只是语法高亮,还要有代码补全、类型检查、跳转定义这些功能。这套东西的核心是 LSP(Language Server Protocol):编辑器前端和语言服务器之间通过标准协议通信,编辑器不需要自己实现语言分析逻辑。

整体架构是这样的:

浏览器 Monaco Editor
       ↕ WebSocket
Node.js 服务端(vscode-ws-jsonrpc)
       ↕ stdio
pyright 语言服务器进程

Monaco Editor 通过 WebSocket 连接到 Node.js 服务端,服务端再把消息转发给 pyright 进程,pyright 负责实际的 Python 分析。

搭建 LSP 服务端

服务端的职责很简单:接受 WebSocket 连接,把消息桥接给 pyright 的 stdio。

JS 版本

初始化项目

mkdir pyright-server && cd pyright-server
pnpm init
mkdir src && touch src/index.js

安装依赖

pnpm add pyright express ws vscode-ws-jsonrpc vscode-languageserver-protocol

配置 package.json,启用 ESM:

{
    "type": "module",
    "scripts": {
        "dev": "node src/index.js"
    }
}

src/index.js 完整代码

import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import express from 'express';
import { WebSocketServer } from 'ws';
import http from 'http';

import { WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc';
import { createConnection, createServerProcess, forward } from 'vscode-ws-jsonrpc/server';

const app = express();
const PORT = 7878;
const server = http.createServer(app);

// noServer: true,手动处理 upgrade 事件
const wsServer = new WebSocketServer({ noServer: true });

server.on('upgrade', (request, socket, head) => {
    wsServer.handleUpgrade(request, socket, head, (ws) => {
        const socket = {
            send: content => ws.send(content, error => { if (error) throw error; }),
            onMessage: cb => ws.on('message', data => cb(data)),
            onError: cb => ws.on('error', cb),
            onClose: cb => ws.on('close', cb),
            dispose: () => ws.close()
        };

        if (ws.readyState === ws.OPEN) {
            launchLanguageServer(socket);
        }
    });
});

server.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
});

function launchLanguageServer(socket) {
    const __filename = fileURLToPath(import.meta.url);
    const currentDirPath = resolve(dirname(__filename));
    const processRunPath = resolve(currentDirPath, '../node_modules/pyright/dist/pyright-langserver.js');

    const reader = new WebSocketMessageReader(socket);
    const writer = new WebSocketMessageWriter(socket);
    const socketConnection = createConnection(reader, writer, () => socket.dispose());

    const serverConnection = createServerProcess(
        'Pyright', 'node', [processRunPath, '--stdio'], {}
    );

    if (serverConnection) {
        forward(socketConnection, serverConnection, message => message);
    }
}

TypeScript 版本

TS 版本结构一样,多了类型定义和 tsconfig 配置。

安装依赖

pnpm add pyright express ws vscode-ws-jsonrpc vscode-languageserver-protocol
pnpm add -D typescript @types/node @types/express @types/ws tsx

初始化 tsconfig

npx tsc --init

编辑 tsconfig.json

{
    "compilerOptions": {
        "moduleResolution": "nodenext",
        "rootDir": "./src",
        "outDir": "./dist",
        "target": "ES2022",
        "module": "NodeNext",
        "esModuleInterop": true,
        "strict": true,
        "skipLibCheck": true
    },
    "include": ["src/**/*.ts"]
}

package.json 启用 ESM,用 tsx 直接运行 TS:

{
    "type": "module",
    "scripts": {
        "dev": "tsx src/index.ts"
    }
}

src/index.ts 完整代码

import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import express from 'express';
import { WebSocketServer } from 'ws';
import http from 'http';

import { IWebSocket, WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc';
import { createConnection, createServerProcess, forward } from 'vscode-ws-jsonrpc/server';
import { Message } from 'vscode-languageserver-protocol';

const app = express();
const PORT = 7878;
const server = http.createServer(app);
const wsServer = new WebSocketServer({ noServer: true });

server.on('upgrade', (request, socket, head) => {
    wsServer.handleUpgrade(request, socket, head, (ws) => {
        const socket: IWebSocket = {
            send: content => ws.send(content, error => { if (error) throw error; }),
            onMessage: cb => ws.on('message', data => cb(data)),
            onError: cb => ws.on('error', cb),
            onClose: cb => ws.on('close', cb),
            dispose: () => ws.close()
        };

        if (ws.readyState === ws.OPEN) {
            launchLanguageServer(socket);
        }
    });
});

server.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
});

function launchLanguageServer(socket: IWebSocket) {
    const __filename = fileURLToPath(import.meta.url);
    const currentDirPath = resolve(dirname(__filename));
    const processRunPath = resolve(currentDirPath, '../node_modules/pyright/dist/pyright-langserver.js');

    const reader = new WebSocketMessageReader(socket);
    const writer = new WebSocketMessageWriter(socket);
    const socketConnection = createConnection(reader, writer, () => socket.dispose());

    const serverConnection = createServerProcess('Pyright', 'node', [processRunPath, '--stdio'], {});

    if (serverConnection) {
        forward(socketConnection, serverConnection, message => {
            if (Message.isRequest(message)) {
                console.log(`← ${message.method}`);
            }
            return message;
        });
    }
}

启动:

pnpm run dev

搭建 Monaco 前端

前端用 Vue + Vite,通过 monaco-editor-wrapper 连接 LSP 服务。

创建项目

pnpm create vite@latest

安装依赖

pnpm add vscode@npm:@codingame/monaco-vscode-api
pnpm add @codingame/monaco-vscode-keybindings-service-override
pnpm add @codingame/monaco-vscode-python-default-extension
pnpm add @codingame/monaco-vscode-editor-api
pnpm add @codingame/monaco-vscode-textmate-service-override
pnpm add monaco-languageclient
pnpm add vscode-ws-jsonrpc
pnpm add monaco-editor-wrapper
pnpm add -D @types/vscode
pnpm add -D @codingame/esbuild-import-meta-url-plugin

vite.config.ts,需要加 esbuild 插件处理 import.meta.url

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import importMetaUrlPlugin from '@codingame/esbuild-import-meta-url-plugin';

export default defineConfig({
    optimizeDeps: {
        esbuildOptions: {
            plugins: [importMetaUrlPlugin]
        },
        include: ['vscode-textmate', 'vscode-oniguruma']
    },
    plugins: [vue()],
})

config/monaco-user-config.ts,配置编辑器和 LSP 连接:

import type { WrapperConfig } from 'monaco-editor-wrapper';
import * as vscode from 'vscode';
import { LogLevel } from 'vscode/services';
import { MonacoLanguageClient } from 'monaco-languageclient';
import { useWorkerFactory } from 'monaco-editor-wrapper/workerFactory';
import '@codingame/monaco-vscode-python-default-extension';
import getKeybindingsServiceOverride from '@codingame/monaco-vscode-keybindings-service-override';
import { createUrl } from 'monaco-languageclient/tools';
import { toSocket, WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc';

export const createUserConfig = (code: string): WrapperConfig => {
    const url = createUrl({ secured: false, host: 'localhost', port: 7878 });
    const webSocket = new WebSocket(url);
    const iWebSocket = toSocket(webSocket);
    const reader = new WebSocketMessageReader(iWebSocket);
    const writer = new WebSocketMessageWriter(iWebSocket);

    const configureMonacoWorkers = () => {
        useWorkerFactory({
            workerOverrides: {
                ignoreMapping: true,
                workerLoaders: {
                    TextEditorWorker: () => new Worker(
                        new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
                        { type: 'module' }
                    ),
                    TextMateWorker: () => new Worker(
                        new URL('@codingame/monaco-vscode-textmate-service-override/worker', import.meta.url),
                        { type: 'module' }
                    )
                }
            }
        });
    };

    return {
        $type: 'extended',
        htmlContainer: document.getElementById('monaco-editor-root')!,
        logLevel: LogLevel.Debug,
        languageClientConfigs: {
            python: {
                name: 'Python Language Server',
                connection: {
                    options: {
                        $type: 'WebSocketDirect',
                        webSocket,
                        startOptions: {
                            onCall: (languageClient?: MonacoLanguageClient) => {
                                setTimeout(() => {
                                    ['pyright.restartserver', 'pyright.organizeimports'].forEach(cmd => {
                                        vscode.commands.registerCommand(cmd, (...args) => {
                                            languageClient?.sendRequest('workspace/executeCommand', { command: cmd, arguments: args });
                                        });
                                    });
                                }, 250);
                            },
                            reportStatus: true,
                        }
                    },
                    messageTransports: { reader, writer }
                },
                clientOptions: {
                    documentSelector: ['python'],
                    workspaceFolder: {
                        index: 0,
                        name: 'workspace',
                        uri: vscode.Uri.parse('/workspace'),
                    },
                }
            }
        },
        vscodeApiConfig: {
            serviceOverrides: { ...getKeybindingsServiceOverride() },
            userConfiguration: {
                json: JSON.stringify({
                    'workbench.colorTheme': 'Default Dark Modern',
                    'editor.wordBasedSuggestions': 'off',
                })
            }
        },
        editorAppConfig: {
            codeResources: {
                modified: { text: code, uri: '/workspace/hello.py' }
            },
            monacoWorkerFactory: configureMonacoWorkers
        }
    };
};

components/Editor.vue

<script setup lang="ts">
import { MonacoEditorLanguageClientWrapper } from 'monaco-editor-wrapper';
import { createUserConfig } from '../config/monaco-user-config';

const wrapper = new MonacoEditorLanguageClientWrapper();

const run = async () => {
    if (wrapper.isStarted()) return;
    // 注意:新版 API 需要传参,见踩坑部分
    await wrapper.init(createUserConfig('print("Hello World")'), true);
    await wrapper.start({ includeLanguageClients: true });
};

const stop = async () => {
    if (!wrapper.isStarted()) return;
    await wrapper.dispose();
};

const getContent = () => {
    const editor = wrapper.getEditor();
    if (editor) console.log(editor.getValue());
};
</script>

<template>
    <div>
        <button @click="run">开启编辑器</button>
        <button @click="stop">关闭编辑器</button>
        <button @click="getContent">获取内容</button>
    </div>
    <div id="monaco-editor-root"></div>
</template>

<style scoped>
#monaco-editor-root {
    width: 800px;
    height: 600px;
    border: 1px solid #ccc;
}
</style>

踩坑记录

1. monaco-editor-wrapper 6.3.0 WebSocket 不工作

升级到 6.3.0 之后,编辑器启动了但和 LSP 服务没有任何交互。查了一下,是 initstart 的 API 签名变了:

// 旧写法(不工作)
await wrapper.init(createUserConfig("print('Hello, World!')"));
await wrapper.start();

// 新写法
await wrapper.init(createUserConfig("print('Hello, World!')"), true);
await wrapper.start({ includeLanguageClients: true });

init 第二个参数 includeLanguageClients 默认值变了,必须显式传 true,否则 Language Client 不会初始化。

2. pyright 无法跨文件检测

在浏览器里打开 test2.py,里面 from test1 import print_hello 会报 Import "test1" could not be resolved,即使 test1.py 也在编辑器里打开着。

原因是 pyright 运行在服务器上,它的文件系统里根本没有 test1.py。Monaco 编辑器里”打开”一个文件,只是在浏览器内存里,不会自动同步到服务器。

解法是在服务端拦截 textDocument/didOpen 事件,把文件内容写到服务器磁盘:

forward(socketConnection, serverConnection, message => {
    if (message.method === 'textDocument/didOpen') {
        const uri = message.params.textDocument.uri;
        const uriPath = fileURLToPath(uri);
        const content = message.params.textDocument.text;
        fs.writeFileSync(uriPath, content);
    }
    return message;
});

同理,textDocument/didChange 也要处理,否则修改内容不会同步。这个方案是临时解法,monaco-languageclient 官方在做自动文件同步的功能(issue #834),等正式支持后会更干净。