NeovimをWasm化し、ブラウザから操作できるようにした

15 min read

はじめに

NeovimはUIと編集エンジンが分離されており、Msgpack-RPCを通じて外部UIから操作できます。それなら、Neovim本体をWebAssemblyへコンパイルし、ブラウザ内で起動できれば、サーバー上のNeovimへ接続するのではなく、ブラウザだけでNeovimを操作できるはずです。

そこで、NeovimをWebAssembly System Interface(WASI)向けにビルドし、ブラウザ上で実行するためのnvim-wasmを作成しました。

NeovimのメンテナーであるJustin M. Keyesさんが、Xでnvim-wasmのリポジトリとデモを紹介してくれました!

リポジトリはこちらです。

最小構成のデモはこちらです。

最小構成のデモでは、ブラウザ上でNeovimを起動し、通常のNeovimと同じようにモードを切り替えて文字を入力できます。

この記事では、NeovimをWasm化するために必要だった処理と、ブラウザから操作する仕組みについて紹介します。

概要

nvim-wasmは、Neovimをwasm32-wasi向けにクロスコンパイルするビルドラッパーと、生成したWasmをブラウザで操作するデモをまとめたリポジトリです。

生成物は次のWASIモジュールです。

build-wasm/bin/nvim

これはホストOSで直接起動する実行ファイルではありません。ブラウザではWeb Worker内のWASIランタイムへ読み込み、仮想ファイルシステムと標準入出力を接続して起動します。

リポジトリには、表示方法と入力方法が異なる4つのデモを用意しています。

デモ表示方法入力方法公開デモ
examples/demoDOMでlinegridを描画SharedArrayBuffer開く
examples/demo-asyncifyDOMでlinegridを描画postMessageとAsyncify開く
examples/demo-monacoMonaco Editorへバッファとカーソルを反映SharedArrayBuffer開く
examples/demo-xtermlinegridをANSIへ変換し、ghostty-webで描画SharedArrayBuffer開く

いずれも、ブラウザ風に再実装したVimではありません。Web Worker上で起動したNeovimへキー入力を送り、Neovimが返す状態を画面へ描画しています。

仕組み

WASI向けにクロスコンパイルする

ビルドにはWASI SDKを使用します。CMakeのツールチェーンファイルでターゲットをwasm32-wasiに設定し、WASI SDKのClang、sysroot、arranlibを指定しています。

Neovimが使用するLua、libuv、luv、lpeg、Tree-sitter、utf8proc、unibiliumもWASI向けの静的ライブラリとしてビルドし、最後にNeovimへリンクします。

WASI SDK、CMake、Binaryenなどは.toolchainsへダウンロードされるため、システム全体へ個別にインストールする必要はありません。

コード生成にはホスト側のLuaを使う

Neovimのビルドでは、途中でLuaを実行してソースコードを生成します。しかし、クロスコンパイル中に生成したWasm版のLuaは、ビルドを実行しているLinux上で直接起動できません。

そのため、最初にホスト上で動くLuaとnlua0をビルドします。

make host-lua

Wasm版Neovimのビルド中にコード生成が必要になると、scripts/build/host_lua_gen.luaを経由してホスト版Luaを実行します。Wasmへ変換するプログラムと、ビルド中に実行するプログラムを分けることで、クロスコンパイルを成立させています。

WASIにない機能を補う

Neovimとlibuvは、通常のUnix環境にあるPTY、signal、pthread、ioctlなどを使用します。一方、WASIの実行環境では同じ機能をそのまま利用できません。

nvim-wasmでは、WASI向けのヘッダーとstubをpatches/wasi-shimに用意しています。また、libuvとluvへ必要な変更を適用し、WASIで使える処理と、ENOSYSを返す未対応処理を分けています。

Neovimのサブモジュール内は変更せず、CMakeのoverrideと外部patchからソースの差し替えやコンパイルオプションの追加を行います。これにより、参照するNeovimを更新したときも、WASI固有の変更点を追いやすくしています。

Web WorkerでNeovimを起動する

NeovimはWeb Worker内で起動します。メインスレッドは画面描画とブラウザの入力を担当し、Neovimの処理はWorker側で実行します。

ブラウザには通常のOSと同じファイルシステムがないため、Neovimのruntime、WASI向けにビルドしたライブラリ、バージョン情報をtar.gzへまとめて配布します。Workerは起動時にこれを仮想ファイルシステムへ展開します。

tar -czf examples/demo/nvim-runtime.tar.gz \
  -C neovim/.. runtime \
  -C build-wasm usr nvim_version.lua

Msgpack-RPCでNeovimと接続する

Neovimは--embedで起動し、標準入出力をMsgpack-RPCの通信路として使用します。

メインスレッドはnvim_ui_attachを呼び出し、ext_linegridのredraw通知を受け取ります。通知には文字、ハイライト、カーソル位置、モードなどが含まれます。最小構成のデモでは、その内容をDOMのグリッドへ直接反映します。

キーボード入力はnvim_inputとしてNeovimへ送ります。画面だけをVim風に描画するのではなく、入力の解釈、モードの遷移、バッファの編集はNeovim自身が行います。

ブラウザのキー入力
  └─ nvim_input
       └─ Web Worker上のNeovim
            └─ redraw通知
                 └─ DOMへ描画

SharedArrayBufferを使用する

通常版のデモでは、標準入力にSharedArrayBufferのリングバッファを使用します。メインスレッドが入力を書き込み、Worker上のNeovimが読み取ります。

SharedArrayBufferをブラウザで使用するには、ページをcross-origin isolatedにする必要があります。デモを配信するサーバーでは、次のCOOP/COEPヘッダーが必要です。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

AsyncifyでSharedArrayBufferを使わない

静的ホスティング先でCOOP/COEPを設定できない場合に備え、SharedArrayBufferを使わないデモも用意しました。

通常のWasm実行中は、Neovimが入力待ちで停止している間にWorkerがpostMessageを受け取れません。そこで、ビルド済みのWasmをBinaryenのAsyncifyで後処理し、WASIのpoll_oneoffで実行を一時停止できるようにします。

make wasm-asyncify

入力が届くまでWasmの状態を退避し、WorkerがpostMessageを受け取ると実行を再開します。この方式ではCOOP/COEPは不要ですが、通常版よりWasmのサイズが増え、実行時のオーバーヘッドも発生します。

現在リポジトリに含まれる生成物では、通常版が約7.8MB、Asyncify版が約8.4MBです。

Asyncify版の公開デモはこちらです。

応用例

Monaco Editor

Monaco版では、編集処理をMonacoへ任せていません。Monacoは読み取り専用とし、キー入力はすべてNeovimへ送ります。

nvim_buf_attachでバッファの変更を受け取り、Neovimのバッファ、カーソル、Visual selectionをMonacoへ反映します。これにより、画面はMonacoですが、編集状態はNeovimが保持します。

Monaco版の公開デモはこちらです。

ターミナルUI

ターミナル版では、ext_linegridの更新をANSIエスケープシーケンスへ変換し、xterm.js互換APIを持つghostty-webで表示します。

NeovimをPTY上で起動して端末画面を転送しているわけではありません。Neovimの外部UIプロトコルから受け取ったグリッドを、ターミナル表示へ変換しています。

ターミナル版の公開デモはこちらです。

ビルド方法

Lua 5.1互換のLuaインタープリター、curltarを用意します。リポジトリはNeovimをサブモジュールとして含むため、--recursiveを付けて取得します。

git clone --recursive https://github.com/MuNeNiCK/nvim-wasm.git
cd nvim-wasm

ホスト側のLua、Wasm向けの依存ライブラリ、Neovimの順にビルドします。

make host-lua
make wasm-deps
make wasm

ビルドが完了すると、次のWASIモジュールが生成されます。

build-wasm/bin/nvim

最小構成のデモを動かす場合は、runtimeとWasmを配置します。

tar -czf examples/demo/nvim-runtime.tar.gz \
  -C neovim/.. runtime \
  -C build-wasm usr nvim_version.lua

cp build-wasm/bin/nvim examples/demo/nvim.wasm
python3 examples/demo/serve.py

ブラウザで次を開きます。

http://localhost:8765/

serve.pyはSharedArrayBufferに必要なCOOP/COEPヘッダーを付けて配信します。Asyncify版はmake demo-asyncifyで生成し、examples/demo-asyncify/serve.pyから起動できます。

制限事項

WASIには通常のデスクトップOSと同じ機能がすべて用意されているわけではありません。現在のnvim-wasmでは、特に次の機能に制限があります。

  • PTYや外部プロセスの起動
  • signalやpthreadに依存する処理
  • 通常のOSと同じネットワーク、ソケット、ファイル監視
  • ネイティブ共有ライブラリを必要とするプラグイン
  • ブラウザ外のファイルシステムへの直接アクセス

現時点では、通常のNeovimをそのまま置き換えるものではなく、Neovimの編集エンジン、Lua、Msgpack-RPC、外部UIをブラウザへ持ち込むための実装です。

今後の展望

Neovim公式のWasm対応

Neovimでは、Wasmを公式のビルドターゲットにする作業が進められています。

2025年8月に作成されたWasm対応のtracking issueでは、ブラウザ内で動作するNeovimの成果物、ビルド手順、CIを整備することが目標として挙げられています。このissueの参考実装にはnvim-wasmも掲載されています。

また、GSoC 2026のtracking issueでは、Emscriptenによる公式ビルドターゲット、ブラウザ向けRPC、SharedArrayBufferを使った標準入出力、ブラウザUIなどの実装が進められています。nvim-wasmはWASI SDKを使用していますが、Neovim本体側ではEmscriptenを使用する方針です。

GSoC 2026の成果として、Emscripten向けのビルド対応を追加するPR #40008は2026年6月にmergeされました。2026年7月30日時点では、ブラウザ上でNeovimを操作するためのPR #40772がopenになっています。

以前、GSoCへの参加を検討していたSarojさんから、nvim-wasmを参考にしてよいか連絡をいただき、了承しました。今後も、Neovim本体へのPRやGSoCの成果物で、nvim-wasmの設計や実装を参考にすることに問題はありません。

Neovim本体へのPRやGSoCの成果物で参照する場合は、ContributorsMuNeNiCKも記載いただけると幸いです。Contributorsという欄がない場合は、Acknowledgementsなどの同等の箇所でも構いません。

vscode-neovimやFirenvimへの活用

nvim-wasmは、デモページだけでなく、Neovimをバックエンドとして利用する既存のツールにも活用できると考えています。

たとえば、vscode-neovimは、VS Codeの編集機能とNeovimを統合する拡張機能です。現在はユーザーがNeovimを別途インストールし、拡張機能がそのNeovimバイナリをバックエンドとして起動します。

vscode-neovimには、NeovimをWasmとして拡張機能へ同梱するfeature requestもあります。Wasm版を利用できれば、ユーザーはNeovimを別途インストールしたり、バックエンドとなるNeovimの実行環境を用意したりせず、拡張機能だけで利用できる可能性があります。拡張機能とNeovimのバージョンを揃えやすくなり、OSごとのバイナリ配布も不要になります。

Firenvimは、WebページのtextareaなどをNeovimで編集できるようにするブラウザ拡張です。現在はFirenvimをローカルのNeovimプラグインとしてインストールし、ブラウザのNative Messagingを通じてNeovimを起動します。

NeovimをWasmとしてブラウザ内で起動できれば、ローカルのNeovimやNative Messaging用のホストを用意せず、ブラウザ拡張だけで同様の仕組みを実現できる可能性があります。Firenvimのような専用UIだけでなく、Webページ上の一般的なテキスト入力をNeovim経由で操作する仕組みにも応用できると思います。

ファイルシステム、設定ファイル、プラグイン、ブラウザの権限制約など、実用化にはまだ解決すべき点があります。興味があれば、ぜひnvim-wasmの実装を参考に取り組んでみてはいかがでしょうか。

おわりに

Neovimをブラウザで動かすには、CをWasmへコンパイルするだけでは足りませんでした。クロスコンパイル中のコード生成、libuvとWASIの差、仮想ファイルシステム、入力待ち、Msgpack-RPC、UI描画まで接続して、初めてブラウザから操作できます。

nvim-wasmでは、最小構成のDOM、Monaco Editor、ターミナル、Asyncifyという異なる方法で、その接続部分を試せるようにしました。

今後、Neovim本体のWasm対応が進む際にも、この実装で得られた知見が役立てばと思います。

参考資料