Browser editor
nxvim runs the real editor — entirely in a browser tab, with no server. Not a
cut-down demo or a syntax-highlighted textarea: nxvim-core plus the PUC Lua 5.4
VM plus the production server tick (autocmds, mirrors, the redraw projection — the
same keystroke path the native server drives) compile to WebAssembly and run
client-side. Your init.lua sources, your keymaps and autocmds fire, files open and
save — all in the tab.
Because the editing engine is local, typing has zero round-trips: motions, operators, undo, and Lua all run in the page. Only the filesystem — and processes, in daemon mode — ever crosses a wire, and even the filesystem can be the browser’s own storage. It’s the edit-host split taken to its limit: the local half is a browser tab; the fs/process half is the browser’s storage or a remote daemon.
What runs
- The whole editor — core + the full Lua VM + the server tick, in a Web Worker.
- Your config —
init.luais read from the browser’s storage at boot and sourced through the real path, so options, keymaps, autocmds, user commands, and highlights all apply. - Files persist —
:e/:wopen and save real files (see Files), surviving a reload. - Syntax highlighting — done JS-side via web-tree-sitter;
:TSInstall <lang>fetches a prebuilt grammar on demand and caches it.
What’s different from native
| In the browser | |
|---|---|
| Plugins | init.lua is one self-contained file — a require of further modules / plugins doesn’t resolve (the runtimepath is empty and storage reads are async). |
| LSP | Works both ways. A language server compiled to JS/wasm runs serverlessly in a Worker (the python demo ships basedpyright); a real native server runs over the daemon (WebTransport). Both ride the same off-tick LSP seam as native. With neither a wasm server nor a daemon, a configured server fails loud, not silently. |
| Native tree-sitter | The in-process parser is gated off the build; highlighting uses the JS-side web-tree-sitter path instead. |
| Processes | Serverless: blocking vim.fn.system always fails loud, and async vim.system / jobstart fail loud too — both need daemon mode (see Files). |
| Hosting | Requires cross-origin isolation (COOP/COEP) for the SharedArrayBuffer. Without it, input still works but timers (nx.timer / vim.defer_fn) don’t fire. |
Anything unavailable fails loud with a named error rather than faking a result — the same no-silent-stubs rule the rest of nxvim follows.
Files
The browser is the filesystem, three ways — all riding the same off-tick fs seam the native edit-host split uses, so only the transport differs:
- OPFS (default, serverless).
:e/:wpersist to the browser’s Origin Private File System, and:e <dir>lists it. Yourinit.luaand shada live there too, so edits and config survive a reload. - Real local files. The File System Access API backs
:eo/:wo(and a bare:won a bound path) — pick a real file or directory on disk through the browser’s permission picker. - A real daemon. Open the page with
?daemon=nxvim://HOST:PORT/TOKEN?cert=HASH(the stringnxvim --daemon --listenprints), or dial it at runtime with:connect nxvim://…, and:e/:woperate on the daemon’s filesystem over WebTransport (HTTP/3 / QUIC). Editing still happens entirely in the tab — only fs crosses the wire. Having no local disk, the browser is always remote-config: it runs the daemon’s config + plugins (fetched over the wire) and keeps shada on the daemon, where a native client would default to local. Daemon mode also brings async processes (vim.system/jobstart),:terminal, and LSP over the wire — the daemon’s real language servers, driven from the tab. A dropped WebTransport link auto-reconnects (the tab’s editor is local, so your buffers survive): the Worker re-dials underneath the seams and re-syncs them — re-opening LSP, re-arming watches, and re-statting open files (a change made while disconnected is caught) — exactly like the native clients.nx.daemon.status()reports the link state.
Run it locally
cd crates/nxvim-edithost
./build.sh # cargo → emcc link → dist/eh.{mjs,wasm} + tree-sitter assets
cd web && npm install # once: Playwright + chromium
node serve.mjs # a cross-origin-isolated (COOP/COEP) dev server
# open http://localhost:8088/web/
node harness.mjs is a headless Node smoke test (feeds ihello<Esc>, asserts the
lines and a real redraw); the verify-*.mjs scripts drive the real wasm editor in a
headless browser (renderer, OPFS, the local-file picker, daemon mode, …).
How it works (in brief)
- A Web Worker owns the editor.
web/worker.mjsis the single!Sendthread holding core + Lua; it loads the wasm module (dist/eh.mjs) and runs the production tick. The UI thread (web/index.html) paints the serverredrawframe as HTML/CSS — a per-cell-span DOM renderer (windows, gutter, status/tabline, pmenu, cursor shapes, smooth scroll) — and translatesKeyboardEvents to vim key-notation. The two talk overpostMessageand a shared ring. - One wait drives input and timers. When cross-origin isolated, the Worker parks
on
Atomics.waitover aSharedArrayBufferinput ring, waking on a keystroke or the next timer deadline — sonx.timer/vim.defer_fnfire without Asyncify, one mechanism. (Without isolation it falls back to apostMessageloop where timers don’t fire.) - Interop is emscripten
ccall/cwrap, not wasm-bindgen — it links the C lua54 backend + vim-regex, so the final link isemcc.src/lib.rsexportseh_input/eh_exec_lua/eh_redraw_json/ the fs legs / … and the redraw returns as JSON. - It’s agent-drivable. The page exposes a
window.__nxvimhook (feed/mouse/execLua/lines/frame) so a headless browser (Playwright) can feed keys and assert on state — the same black-box style as the native test harness. - Built outside the workspace. It targets
wasm32-unknown-emscriptenand links C viaemcc, so it sits in the rootCargo.toml’s[workspace] exclude(a hostcargo build --workspacenever touches it) and pins its own deps. Deployed as static files (netlify.toml); the one hard requirement is cross-origin isolation.
See also
- The edit-host split — the same split reached natively, with
the editor local and an
nxvim --daemonserving fs / processes over ssh or QUIC. - Architecture → the web build — the crate layout and the full redraw/interop projection.
- The edit-host & browser-Lua plan — the design and its phased implementation.