How to Build Browser Vim for Node.js That Can Open Project Files and Run Code
Putting vim-wasm on a page is one job. Making it work on a Node.js project is another.
Most browser Vim demos stop at a single buffer. You can type, maybe save to browser storage, and that is the end of it. As soon as you want to open project files, run npm install, start a dev server, or inspect a directory, you need a runtime the editor can talk to.
The web_vim_with_node app keeps Vim in the browser, but gives it a BrowserPod workspace under /vimamp. That lets Vim open files from the pod, sync buffers back into it, run Node commands from the console, and keep track of the last portal URL when a server starts.
Boot BrowserPod when it is available
The app does not assume BrowserPod is always present. It checks for an API key and boot conditions first:
async function bootBrowserPodIfAvailable() {
const apiKey = getApiKey();
if (!apiKey) {
appState.setPodStatus("standalone", {
message: "No API key configured.",
lastError: null,
});
return;
}
const { pod, terminal } = await bootPod({
apiKey,
terminalElement,
});
state.pod = pod;
state.terminal = terminal;
state.podReady = true;
}
When BrowserPod does boot, it creates the terminal and prepares one filesystem root for the session:
export async function bootPod({ apiKey, terminalElement, onStep = () => {} }) {
const pod = await BrowserPod.boot({ apiKey });
await delay(BOOT_STABILIZATION_MS);
const terminal = await pod.createDefaultTerminal(terminalElement);
await pod.createDirectory("/vimamp", { recursive: true });
return { pod, terminal };
}
That /vimamp root gives the editor, filesystem browser, and command runner one place to agree on.
Add Vim commands for BrowserPod work
The key step is the bridge between Vim commands and BrowserPod actions.
The custom commands live in vimrc:
command! -nargs=0 BPSave call jsevalfunc('window.__bpSyncFromVim(arguments[0], arguments[1]);', [expand('%:p'), join(getline(1, "$"), "\\n")], v:true)
command! -nargs=1 BPEdit call jsevalfunc('window.__bpOpenFromPod(arguments[0]);', [<q-args>], v:true)
command! -nargs=? BPLs call jsevalfunc('window.__bpListFromPod(arguments[0]);', [<q-args>], v:true)
command! -nargs=* BP call jsevalfunc('window.__bpRunCommand(arguments[0]);', [<q-args>], v:true)
Those commands are backed by JavaScript functions installed at app startup:
window.__bpRunCommand = (command) => {
const text = String(command ?? "").trim();
if (!text) return Promise.resolve();
openConsole();
return runCommand(text);
};
window.__bpOpenFromPod = (pathArg) => openPodFileInEditor(pathArg);
window.__bpListFromPod = (pathArg) => listPodDirectoryInEditor(pathArg);
Keep Vim and the pod on the same file path
The editor starts with /vimamp mounted as its working directory:
vim.start({
dirs: ["/vimamp"],
files: {
[initialFilePath]: initialText,
"/home/web_user/.vim/vimrc": vimRc,
},
cmdArgs: [initialFilePath],
clipboard: true,
});
When the user runs :BPSave, the current buffer is written into BrowserPod:
async function syncFromVim(fullPath, contents) {
if (!state.podReady || !state.pod) {
throw new Error("BrowserPod is not ready. :BPSave is unavailable.");
}
await writeFileText(fullPath, contents, { trackActivePath: true });
setStatus(`Synced ${fullPath}`);
}
And :BPEdit /vimamp/main.js pulls the file from BrowserPod and opens it in Vim:
async function openPodFileInEditor(pathArg) {
const fullPath = normalizePodPath(
pathArg,
state.activeFilePath || demoFilePath
);
const text = await ensureTextFile({
pod: state.pod,
fullPath,
defaultText: "",
});
await state.vimEditor.openFile(fullPath, text);
appState.setActiveFilePath(fullPath);
setStatus(`Opened ${fullPath} from BrowserPod.`);
return true;
}
Give the console local file commands and pass Node commands through
The shell command service handles file-oriented commands itself and only passes npm and node through to BrowserPod:
const passThroughCommand = executable === "npm" || executable === "node";
if (executable === "cd") {
shellState.cwd = normalizeFilesystemPath(targetPath);
await writeTerminalLine(state, shellState.cwd);
return;
}
if (passThroughCommand) {
await runnerService.runCommand(text, {
cwd: shellState.cwd,
echo: false,
timeoutMs,
});
return;
}
cd, ls, cat, mkdir, touch, mv, and rm work against the BrowserPod filesystem model. node and npm run as BrowserPod processes.
Surface the last portal instead of embedding a preview
This app does not iframe the running project into the editor. It stores the latest portal URL and gives the user a menu action to open it:
function handleBrowserPodPortal(portal) {
portalUiState.lastUrl = String(portal?.url || "").trim();
setPortalActionEnabled(true);
setStatus(`Portal is ready. View > Open Last Portal.`);
}
For a Vim-first workspace, the editor stays on editing and commands, while the running app opens in its own browser tab.
Where this fits
Use this when you want Vim as the editing surface, but you still need a Node.js workspace behind it.
Examples:
- a Vim-based coding exercise with a real project tree
- an internal tool where users edit files in Vim and run a small Node command set
- a teaching environment for command-line workflows without local setup
- a browser sandbox for a small Node.js app that needs an editor and a console
If you need long-lived remote workspaces, background jobs after the tab closes, or native binaries everywhere, build a different environment. If you want browser Vim that can work on a Node.js project, this split gets there with fewer moving parts.