Skip to content

selimacerbas/live-server.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 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.
  • Port 0 (OS-assigned): pass port = 0 to let the OS pick a free port. The actual port is available via inst.port after server.start().
  • Same port, new path: reusing the same port retargets the server → same URL, so browsers typically reuse the same tab.
  • Event injection: GET /__live/inject?event=<type>&data=<json> lets external processes broadcast SSE events to connected clients.
  • 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
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

Server-level API (for plugin authors)

local server = require("live_server.server")
local util   = require("live_server.util")

local inst = server.start({
  port = 0,                                   -- port 0 = OS-assigned
  root = "/path",
  token = util.random_token(16),              -- optional, see "Token auth" below
  protected_paths = { "^/content%.md$" },     -- Lua patterns; require ?t=<token>
})
server.send_event(inst, "scroll", '{"line":42}')               -- broadcast custom SSE event
server.reload(inst, "file.html")                                -- broadcast reload event
server.update_target(inst, new_root, new_index)                 -- retarget without restart
server.connected_client_count(inst)                             -- number of SSE clients
server.stop(inst)                                               -- shut down

HTTP event injection

External processes can inject SSE events via HTTP:

GET /__live/inject?event=<type>&data=<url-encoded-json>[&t=<token>]

This broadcasts the event to all connected SSE clients. Used by markdown-preview.nvim for cross-instance scroll sync. The t=<token> parameter is required when the server was started with cfg.token.

Token auth (optional)

When cfg.token is set, the server requires ?t=<token> on:

  • /__live/events (the SSE stream)
  • /__live/inject (event injection)
  • Any path matching one of the Lua patterns in cfg.protected_paths

Static assets (index.html, style.css, etc.) are intentionally not gated because the browser bootstraps from them before any JS runs and cannot append query strings to tags it discovers itself. Token-bearing requests for everything else are the caller's responsibility: pass ?t=<token> to the browser via the initial URL, then stash it in sessionStorage and append it on every fetch/EventSource call.

util.random_token(byte_len) generates a hex token (default 16 bytes = 128 bits) from /dev/urandom, falling back to math.random seeded from uv.hrtime + os.time + pid. util.secure_compare(a, b) is a constant-time-ish string compare for token validation.

Token auth is opt-in. When cfg.token is nil (the default), no auth is applied and all endpoints behave as before.


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

 
 
 

Contributors

Languages