Skip to content

A tiny, zero-dependency local web server for Neovim — written in pure Lua with `vim.loop`. Start a server on any file or folder, auto-reload the browser on save, and quickly reopen existing ports.

License

Notifications You must be signed in to change notification settings

selimacerbas/live-server.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

live-server.nvim

A tiny, zero-dependency local web server for Neovim — written in pure Lua with vim.loop. Start a server on any file or folder, auto-reload the browser on save, and quickly reopen existing ports.

  • Pure Lua: no npm, no Python, no binaries.
  • Local-only: binds to 127.0.0.1 (loopback).
  • SSE live-reload: instant page refresh on file changes (debounced).
  • CSS hot-inject: stylesheet changes apply instantly without a full page reload.
  • Directory listing: clean index when no index.html exists.
  • Telescope UX: pick a path (file or directory) and a port from a friendly picker.
  • Which-key friendly: group label in init, real mappings in keys, no conflicts.
  • Same-port retargeting: starting on the same port updates the served root/index (reuses the same browser tab/URL).
  • Auto-start: optionally start a server when you open an HTML file.
  • Statusline: show active servers in your statusline/lualine.

This plugin serves only on 127.0.0.1. It's meant for local dev previews, not production.


Requirements

  • Neovim 0.8+ (tested on 0.9 / 0.10).
  • Linux, macOS, or Windows.
  • telescope.nvim recommended for the best picking UX (falls back to vim.ui.select/input if missing).
  • which-key.nvim recommended.

Installation (lazy.nvim)

-- lua/plugins/live-server.lua
return {
  "selimacerbas/live-server.nvim",
  dependencies = {
    "folke/which-key.nvim",
    "nvim-telescope/telescope.nvim", -- recommended for path picker
  },
  init = function()
    -- which-key group label only (best practice)
    local ok, wk = pcall(require, "which-key")
    if ok then wk.add({ { "<leader>l", group = "LiveServer" } }) end
  end,
  opts = {
    default_port = 8000,
    live_reload = { enabled = true, inject_script = true, debounce = 120, css_inject = true },
    directory_listing = { enabled = true, show_hidden = false },
  },
  -- map to user commands (robust lazy-loading)
  keys = {
    { "<leader>ls", "<cmd>LiveServerStart<cr>",      desc = "Start (pick path & port)" },
    { "<leader>lo", "<cmd>LiveServerOpen<cr>",       desc = "Open existing port in browser" },
    { "<leader>lr", "<cmd>LiveServerReload<cr>",     desc = "Force reload (pick port)" },
    { "<leader>lt", "<cmd>LiveServerToggleLive<cr>", desc = "Toggle live-reload (pick port)" },
    { "<leader>li", "<cmd>LiveServerStatus<cr>",     desc = "Show server status" },
    { "<leader>lS", "<cmd>LiveServerStop<cr>",       desc = "Stop one (pick port)" },
    { "<leader>lA", "<cmd>LiveServerStopAll<cr>",    desc = "Stop all" },
  },
  config = function(_, opts)
    require("live_server").setup(opts)
  end,
}

Usage

Start a server

  • Press <leader>ls (or run :LiveServerStart).
  • Pick a path (file or directory), then pick a port (default 8000).
  • Your browser opens http://127.0.0.1:<port>/.

If you pick a file, the server serves the file's folder with that file as the default index. If you pick the same port again later, the server retargets to the new root/index instead of creating a new instance.

Other commands

Command Description
:LiveServerStart Pick a path and port, start serving
:LiveServerOpen Pick a port, open its URL in browser
:LiveServerReload Force reload all connected clients
:LiveServerToggleLive Enable/disable file watching for a port
:LiveServerStatus Show running servers (port, root, uptime, clients)
:LiveServerStop Pick a port to stop
:LiveServerStopAll Stop all servers

Options

Configured via require("live_server").setup({...}) or opts = { ... } in your lazy spec.

{
  default_port     = 8000,           -- default suggestion in the port picker
  open_on_start    = true,           -- open browser after start/retarget
  notify           = true,           -- use vim.notify for events
  notify_on_reload = false,          -- notify on every live-reload event
  headers          = { ["Cache-Control"] = "no-cache" }, -- extra response headers
  cors             = false,          -- true/"*" or origin string (e.g. "http://localhost:3000")
  index_names      = { "index.html", "index.htm" }, -- index files to try in order

  auto_start = nil,                  -- set to auto-start on filetype, e.g.:
  -- auto_start = { filetypes = { "html" }, port = 8000 },

  live_reload = {
    enabled       = true,            -- watch files under the served root
    inject_script = true,            -- injects <script src="/__live/script.js">
    debounce      = 120,             -- ms debounce for rapid changes
    css_inject    = true,            -- hot-swap CSS without full page reload
  },

  directory_listing = {
    enabled     = true,              -- render an index page if no index.html
    show_hidden = false,             -- include dotfiles in listing
  },
}

Features

CSS hot-inject

When css_inject is enabled (default), editing a .css file triggers an instant stylesheet swap in the browser — no full page reload, no DOM state lost. All other file changes still trigger a full reload.

Auto-start

Set auto_start to automatically start a server when you open a matching filetype:

auto_start = { filetypes = { "html" }, port = 8000 }

The server starts once per directory — opening another HTML file in the same folder won't spawn a duplicate.

.liveignore

Create a .liveignore file in your served root to skip file-watcher noise. One pattern per line, * as wildcard, # for comments:

# Don't reload on these
node_modules
*.log
.git
dist

CORS

Enable cross-origin headers for all responses:

cors = true,                         -- Access-Control-Allow-Origin: *
cors = "http://localhost:3000",      -- specific origin

Useful when your frontend (on the live server) makes API calls to a separate backend.

Statusline

Show active servers in lualine or any statusline:

-- lualine example
sections = {
  lualine_x = {
    { require("live_server").statusline },
  },
}

Returns "[LS :8000]" when a server is running, or "" when idle.

Styled error pages

404 and 400 errors display a clean, dark-mode-aware HTML page instead of raw text — easier to spot during development.


Keymaps (default)

All under the which-key group <leader>l:

Key Action
<leader>ls Start (pick path & port)
<leader>lo Open existing port in browser
<leader>lr Force reload (pick port)
<leader>lt Toggle live-reload (pick port)
<leader>li Show server status
<leader>lS Stop one (pick port)
<leader>lA Stop all

We register only the group label in init, and return actual mappings in keys — the recommended pattern for Folke's ecosystem to avoid conflicts and enable lazy-loading on keypress.


Design notes

  • Local by default: binds to 127.0.0.1. If you want LAN, you can change the bind address in server.lua (not recommended for security).
  • Path safety: requests are realpath-checked to prevent escaping the served root.
  • Index resolution: root directory → default_index (if starting from a file) → index_names in order → directory listing. Subdirectories always use their own index files.
  • Same port, new path: reusing the same port retargets the server → same URL, so browsers typically reuse the same tab.
  • Graceful exit: all servers are automatically stopped on VimLeavePre.

Troubleshooting

  • "Port in use or failed to bind" Another process is using that port (or a previous server didn't exit cleanly). Pick a different port, or stop the other process. You can stop live-server instances via :LiveServerStop or :LiveServerStopAll.

  • "start() bad argument #2 to 'start' (table expected, got number)" Some luv builds expect fs_event:start(path, {recursive=true}, cb) while others accept start(path, cb). The plugin tries both. Make sure you're on the latest plugin files.

  • Browser didn't open We try vim.ui.open (NVIM 0.10) and fall back to xdg-open/open/start. If none work, copy the URL from the message and open manually.

  • Live-reload didn't trigger

    • It only injects into HTML pages.
    • Ensure the served root actually changed (the watcher is per root).
    • Check .liveignore isn't excluding the file.
    • Try :LiveServerToggleLive off/on, or :LiveServerReload to force.

API (for lua configs)

local ls = require("live_server")

ls.setup({ ... })                -- configure defaults
ls.start_picker()                -- UI flow: pick path, then port
ls.open_existing()               -- pick a port → open in browser
ls.force_reload()                -- broadcast reload to clients
-- Server-level API (for plugin authors using live-server.nvim as a dependency):
-- server.send_event(inst, event_type, data)  -- send a custom SSE event to all connected browsers
ls.toggle_livereload()           -- enable/disable live-reload for a port
ls.status()                      -- print running server info
ls.statusline()                  -- returns "[LS :8000]" or ""
ls.stop_one()                    -- pick a port → stop
ls.stop_all()                    -- stop everything

Roadmap

  • Optional LAN binding with allowlist.
  • Pluggable middlewares (custom headers, rewrites).
  • Directory listing customization (sorting, columns).

Contributing

PRs and issues are welcome! Please include your OS, Neovim version, and (if relevant) vim.loop/luv version when reporting bugs. Repro steps make fixes fast.


License

MIT © Selim Acerbaş

About

A tiny, zero-dependency local web server for Neovim — written in pure Lua with `vim.loop`. Start a server on any file or folder, auto-reload the browser on save, and quickly reopen existing ports.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages