feat: replace npm live-server with pure-Lua HTTP server (#29)

## Problem

The plugin requires users to install Node.js and the `live-server` npm
package globally. This is a heavyweight external dependency for what
amounts to a simple local dev-server workflow, and it creates friction
for users who don't otherwise need Node.js.

## Solution

Replace the npm shell-out with a pure-Lua HTTP server built on `vim.uv`
(libuv bindings), eliminating all external dependencies. The new server
supports static file serving, SSE-based live reload, CSS hot-swap
without full page reload, directory listings, and recursive file
watching with configurable debounce.

Minimum Neovim version is bumped to 0.10 for `vim.uv` and `vim.ui.open`.
The old `args`-based config is automatically migrated with a deprecation
warning.

Closes #28.
This commit is contained in:
Barrett Ruth 2026-03-02 23:16:35 -05:00 committed by GitHub
parent baeb211719
commit f42f958c24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1097 additions and 298 deletions

View file

@ -0,0 +1,42 @@
local M = {}
function M.check()
vim.health.start('live-server.nvim')
if vim.fn.has('nvim-0.10') == 1 then
vim.health.ok('Neovim >= 0.10')
else
vim.health.error(
'Neovim >= 0.10 is required',
{ 'Upgrade Neovim or pin live-server.nvim to v0.1.6' }
)
end
if vim.uv then
vim.health.ok('vim.uv is available')
else
vim.health.error('vim.uv is not available', { 'Neovim >= 0.10 provides vim.uv' })
end
local user_config = vim.g.live_server or {}
if user_config.args then
vim.health.warn(
'deprecated `args` config detected',
{ 'See `:h live-server-config` for the new format' }
)
else
vim.health.ok('no deprecated config detected')
end
if jit.os == 'Linux' then
vim.health.warn('recursive file watching is not supported on Linux', {
'Only files in the root directory will trigger reload. See `:h live-server-linux-recursive`',
})
end
if vim.fn.executable('live-server') == 1 then
vim.health.info('npm `live-server` is installed but no longer required')
end
end
return M

238
lua/live-server/init.lua Normal file
View file

@ -0,0 +1,238 @@
local server = require('live-server.server')
local M = {}
---@type boolean
local initialized = false
---@type table<string, live_server.Instance>
local instances = {}
---@class live_server.Config
---@field port? integer
---@field browser? boolean
---@field debounce? integer
---@field ignore? string[]
---@field css_inject? boolean
---@field debug? boolean
---@type live_server.Config
local defaults = {
port = 5500,
browser = true,
debounce = 120,
ignore = {},
css_inject = true,
debug = false,
}
---@type live_server.Config
local config = vim.deepcopy(defaults)
---@param message string
---@param level string
local function log(message, level)
vim.notify(('live-server.nvim: %s'):format(message), vim.log.levels[level])
end
---@type table<string, boolean>
local UNSUPPORTED_FLAGS = {
['--host'] = true,
['--open'] = true,
['--browser'] = true,
['--quiet'] = true,
['--entry-file'] = true,
['--spa'] = true,
['--mount'] = true,
['--proxy'] = true,
['--htpasswd'] = true,
['--cors'] = true,
['--https'] = true,
['--https-module'] = true,
['--middleware'] = true,
['--ignorePattern'] = true,
}
---@param user_config table
---@return table
local function migrate_args(user_config)
if not user_config.args then
return user_config
end
vim.deprecate(
'`vim.g.live_server.args`',
'`:h live-server-config`',
'v0.2.0',
'live-server.nvim',
false
)
local migrated = {}
for k, v in pairs(user_config) do
if k ~= 'args' then
migrated[k] = v
end
end
for _, arg in ipairs(user_config.args) do
local port = arg:match('%-%-port=(%d+)')
if port then
migrated.port = tonumber(port)
elseif arg == '--no-browser' then
migrated.browser = false
elseif arg == '--no-css-inject' then
migrated.css_inject = false
else
local wait = arg:match('%-%-wait=(%d+)')
if wait then
migrated.debounce = tonumber(wait)
else
local ignore_val = arg:match('%-%-ignore=(.*)')
if ignore_val then
migrated.ignore = migrated.ignore or {}
for pattern in ignore_val:gmatch('[^,]+') do
migrated.ignore[#migrated.ignore + 1] = vim.trim(pattern)
end
else
local flag = arg:match('^(%-%-[%w-]+)')
if flag and UNSUPPORTED_FLAGS[flag] then
log(('flag `%s` is not supported and will be ignored'):format(arg), 'WARN')
end
end
end
end
end
return migrated
end
local function init()
if initialized then
return
end
local user_config = vim.g.live_server or {}
user_config = migrate_args(user_config)
config = vim.tbl_deep_extend('force', defaults, user_config)
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
for dir, inst in pairs(instances) do
server.stop(inst)
instances[dir] = nil
end
end,
})
initialized = true
end
---@param dir? string
---@return string?
local function find_cached_dir(dir)
if not dir then
return nil
end
local cur = dir
while not instances[cur] do
if cur == '/' or cur:match('^[A-Z]:\\$') then
return nil
end
cur = vim.fn.fnamemodify(cur, ':h')
end
return cur
end
---@param dir string
---@return live_server.Instance?
local function is_running(dir)
local cached_dir = find_cached_dir(dir)
return cached_dir and instances[cached_dir]
end
---@param dir? string
---@return string
local function resolve_dir(dir)
if not dir or dir == '' then
dir = '%:p:h'
end
return vim.fn.expand(vim.fn.fnamemodify(vim.fn.expand(dir), ':p'))
end
---@param dir? string
function M.start(dir)
init()
dir = resolve_dir(dir)
if is_running(dir) then
log('already running', 'INFO')
return
end
local root_real = vim.uv.fs_realpath(dir)
if not root_real then
log(('directory does not exist: %s'):format(dir), 'ERROR')
return
end
local inst = server.start({
port = config.port,
root_real = root_real,
debounce = config.debounce,
ignore = config.ignore,
css_inject = config.css_inject,
debug = config.debug,
})
instances[dir] = inst
log(('started on 127.0.0.1:%d'):format(config.port), 'INFO')
if config.browser then
vim.ui.open(('http://127.0.0.1:%d/'):format(config.port))
end
end
---@param dir? string
function M.stop(dir)
dir = resolve_dir(dir)
local cached_dir = find_cached_dir(dir)
if cached_dir and instances[cached_dir] then
server.stop(instances[cached_dir])
instances[cached_dir] = nil
log('stopped', 'INFO')
end
end
---@param dir? string
function M.toggle(dir)
dir = resolve_dir(dir)
if is_running(dir) then
M.stop(dir)
else
M.start(dir)
end
end
---@deprecated Use `vim.g.live_server` instead
---@param user_config? live_server.Config
function M.setup(user_config)
vim.deprecate(
'`require("live-server").setup()`',
'`vim.g.live_server`',
'v0.1.0',
'live-server.nvim',
false
)
if user_config then
vim.g.live_server = vim.tbl_deep_extend('force', vim.g.live_server or {}, user_config)
end
initialized = false
init()
end
return M

636
lua/live-server/server.lua Normal file
View file

@ -0,0 +1,636 @@
local uv = vim.uv
---@class live_server.Instance
---@field handle uv.uv_tcp_t
---@field port integer
---@field root_real string
---@field sse_clients uv.uv_tcp_t[]
---@field debounce_timer uv.uv_timer_t
---@field fs_event uv.uv_fs_event_t?
---@field ignore_patterns string[]
---@field debounce_ms integer
---@field css_inject boolean
---@field debug boolean
---@class live_server.StartConfig
---@field port integer
---@field root_real string
---@field debounce? integer
---@field ignore? string[]
---@field css_inject? boolean
---@field debug? boolean
local S = {}
local function dbg(inst, msg)
if not inst.debug then
return
end
vim.schedule(function()
vim.notify(('[live-server] %s'):format(msg), vim.log.levels.DEBUG)
end)
end
---@type table<string, string>
local MIME_TYPES = {
html = 'text/html; charset=utf-8',
htm = 'text/html; charset=utf-8',
css = 'text/css; charset=utf-8',
js = 'application/javascript; charset=utf-8',
mjs = 'application/javascript; charset=utf-8',
json = 'application/json; charset=utf-8',
xml = 'application/xml; charset=utf-8',
svg = 'image/svg+xml',
png = 'image/png',
jpg = 'image/jpeg',
jpeg = 'image/jpeg',
gif = 'image/gif',
ico = 'image/x-icon',
webp = 'image/webp',
woff = 'font/woff',
woff2 = 'font/woff2',
ttf = 'font/ttf',
otf = 'font/otf',
txt = 'text/plain; charset=utf-8',
md = 'text/plain; charset=utf-8',
wasm = 'application/wasm',
}
---@type table<integer, string>
local REASON_PHRASES = {
[200] = 'OK',
[301] = 'Moved Permanently',
[400] = 'Bad Request',
[404] = 'Not Found',
[405] = 'Method Not Allowed',
[500] = 'Internal Server Error',
}
---@type integer
local CHUNK_SIZE = 65536
---@type string
local CLIENT_JS = [[
(function() {
var es = new EventSource('/__live/events');
es.addEventListener('reload', function(e) {
var data = JSON.parse(e.data);
if (data.css) {
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) {
var href = links[i].href.replace(/[?&]_lr=\d+/, '');
links[i].href = href + (href.indexOf('?') >= 0 ? '&' : '?') + '_lr=' + Date.now();
}
} else {
location.reload();
}
});
})();
]]
---@type string
local INJECT_TAG = '<script src="/__live/script.js"></script>'
---@param str string
---@return string
local function url_decode(str)
return (str:gsub('%%(%x%x)', function(hex)
return string.char(tonumber(hex, 16))
end))
end
---@param path string
---@return string
local function get_mime(path)
local ext = path:match('%.([^%.]+)$')
if ext then
return MIME_TYPES[ext:lower()] or 'application/octet-stream'
end
return 'application/octet-stream'
end
---@param path string
---@return boolean
local function is_html(path)
local ext = path:match('%.([^%.]+)$')
return ext and (ext:lower() == 'html' or ext:lower() == 'htm')
end
---@param status integer
---@return string
local function response_line(status)
return ('HTTP/1.1 %d %s\r\n'):format(status, REASON_PHRASES[status] or 'Unknown')
end
---@param sock uv.uv_tcp_t
---@param status integer
---@param headers table<string, string>
---@param body? string
local function write_response(sock, status, headers, body)
local parts = { response_line(status) }
headers['Connection'] = 'close'
if body then
headers['Content-Length'] = tostring(#body)
end
for k, v in pairs(headers) do
parts[#parts + 1] = ('%s: %s\r\n'):format(k, v)
end
parts[#parts + 1] = '\r\n'
if body then
parts[#parts + 1] = body
end
local ok = pcall(sock.write, sock, table.concat(parts), function()
pcall(sock.shutdown, sock, function()
if not sock:is_closing() then
sock:close()
end
end)
end)
if not ok and not sock:is_closing() then
sock:close()
end
end
---@param sock uv.uv_tcp_t
---@param status integer
local function error_response(sock, status)
local phrase = REASON_PHRASES[status] or 'Error'
local body = ([[<html><body><h1>%d %s</h1></body></html>]]):format(status, phrase)
write_response(sock, status, { ['Content-Type'] = 'text/html; charset=utf-8' }, body)
end
---@param root string
---@param request_path string
---@return string?
local function resolve_path(root, request_path)
local decoded = url_decode(request_path)
local joined = ('%s/%s'):format(root, decoded)
local real = uv.fs_realpath(joined)
if not real then
return nil
end
if real:sub(1, #root) ~= root then
return nil
end
return real
end
---@param sock uv.uv_tcp_t
---@param filepath string
local function serve_file_streaming(sock, filepath)
uv.fs_open(filepath, 'r', 438, function(err_open, fd)
if err_open or not fd then
vim.schedule(function()
error_response(sock, 500)
end)
return
end
uv.fs_fstat(fd, function(err_stat, stat)
if err_stat or not stat then
uv.fs_close(fd)
vim.schedule(function()
error_response(sock, 500)
end)
return
end
local mime = get_mime(filepath)
local size = stat.size
if is_html(filepath) then
uv.fs_read(fd, size, 0, function(err_read, data)
uv.fs_close(fd)
if err_read or not data then
vim.schedule(function()
error_response(sock, 500)
end)
return
end
local lower = data:lower()
local inject_pos = lower:find('</body>')
if inject_pos then
data = ('%s%s\n%s'):format(
data:sub(1, inject_pos - 1),
INJECT_TAG,
data:sub(inject_pos)
)
else
data = ('%s\n%s'):format(data, INJECT_TAG)
end
local response = ('%s\z
Content-Type: %s\r\n\z
Content-Length: %d\r\n\z
Connection: close\r\n\z
\r\n\z
%s'):format(response_line(200), mime, #data, data)
local ok = pcall(sock.write, sock, response, function()
pcall(sock.shutdown, sock, function()
if not sock:is_closing() then
sock:close()
end
end)
end)
if not ok and not sock:is_closing() then
sock:close()
end
end)
return
end
local header = ('%s\z
Content-Type: %s\r\n\z
Content-Length: %d\r\n\z
Connection: close\r\n\z
\r\n'):format(response_line(200), mime, size)
local offset = 0
---@type fun()
local function read_chunk()
local to_read = math.min(CHUNK_SIZE, size - offset)
if to_read <= 0 then
uv.fs_close(fd)
pcall(sock.shutdown, sock, function()
if not sock:is_closing() then
sock:close()
end
end)
return
end
uv.fs_read(fd, to_read, offset, function(err_chunk, chunk)
if err_chunk or not chunk or #chunk == 0 then
uv.fs_close(fd)
if not sock:is_closing() then
sock:close()
end
return
end
offset = offset + #chunk
local wok = pcall(sock.write, sock, chunk, function()
read_chunk()
end)
if not wok then
uv.fs_close(fd)
if not sock:is_closing() then
sock:close()
end
end
end)
end
local ok = pcall(sock.write, sock, header, function()
read_chunk()
end)
if not ok then
uv.fs_close(fd)
if not sock:is_closing() then
sock:close()
end
end
end)
end)
end
---@param sock uv.uv_tcp_t
---@param dirpath string
---@param url_path string
---@param root string
local function serve_directory_listing(sock, dirpath, url_path, root)
uv.fs_scandir(dirpath, function(err, handle)
if err or not handle then
vim.schedule(function()
error_response(sock, 500)
end)
return
end
---@type string[]
local dirs = {}
---@type string[]
local files = {}
while true do
local name, typ = uv.fs_scandir_next(handle)
if not name then
break
end
if typ == 'directory' then
dirs[#dirs + 1] = name
else
files[#files + 1] = name
end
end
table.sort(dirs)
table.sort(files)
local prefix = url_path
if prefix:sub(-1) ~= '/' then
prefix = prefix .. '/'
end
---@type string[]
local entries = {}
if dirpath ~= root then
entries[#entries + 1] = '<li><a href="../">../</a></li>'
end
for _, d in ipairs(dirs) do
entries[#entries + 1] = ('<li><a href="%s%s/">%s/</a></li>'):format(prefix, d, d)
end
for _, f in ipairs(files) do
entries[#entries + 1] = ('<li><a href="%s%s">%s</a></li>'):format(prefix, f, f)
end
local body = ([[
<html>
<head>
<meta charset="utf-8">
<title>Index of %s</title>
<style>body{font-family:monospace;padding:1em}a{text-decoration:none}a:hover{text-decoration:underline}li{line-height:1.6}</style>
</head>
<body>
<h1>Index of %s</h1>
<ul>%s</ul>
%s
</body>
</html>]]):format(prefix, prefix, table.concat(entries), INJECT_TAG)
vim.schedule(function()
write_response(sock, 200, { ['Content-Type'] = 'text/html; charset=utf-8' }, body)
end)
end)
end
---@param path string
---@param patterns string[]
---@return boolean
local function should_ignore(path, patterns)
for _, pattern in ipairs(patterns) do
if path:find(pattern) then
return true
end
end
return false
end
---@param inst live_server.Instance
---@param event string
---@param payload string
local function sse_broadcast(inst, event, payload)
dbg(inst, ('sse_broadcast: %d client(s), event=%s'):format(#inst.sse_clients, event))
local msg = ('event: %s\ndata: %s\n\n'):format(event, payload)
---@type uv.uv_tcp_t[]
local alive = {}
for _, client in ipairs(inst.sse_clients) do
local ok = pcall(client.write, client, msg)
if ok then
alive[#alive + 1] = client
else
if not client:is_closing() then
pcall(client.close, client)
end
end
end
inst.sse_clients = alive
end
---@param inst live_server.Instance
---@param sock uv.uv_tcp_t
---@param raw string
local function handle_request(inst, sock, raw)
local method, path = raw:match('^(%u+)%s+([^%s]+)')
if not method or not path then
error_response(sock, 400)
return
end
if method ~= 'GET' then
error_response(sock, 405)
return
end
path = path:gsub('%?.*$', '')
if path == '/__live/events' then
dbg(inst, 'request: /__live/events')
local header = ('%s\z
Content-Type: text/event-stream\r\n\z
Cache-Control: no-cache\r\n\z
Connection: keep-alive\r\n\z
\r\nretry: 1000\n\n'):format(response_line(200))
local ok = pcall(sock.write, sock, header)
if ok then
inst.sse_clients[#inst.sse_clients + 1] = sock
dbg(inst, ('sse_client connected (%d total)'):format(#inst.sse_clients))
sock:read_start(function(read_err, data)
if read_err or not data then
for i, c in ipairs(inst.sse_clients) do
if c == sock then
table.remove(inst.sse_clients, i)
break
end
end
dbg(inst, ('sse_client disconnected (%d remaining)'):format(#inst.sse_clients))
if not sock:is_closing() then
sock:close()
end
end
end)
else
if not sock:is_closing() then
sock:close()
end
end
return
end
if path == '/__live/script.js' then
dbg(inst, 'request: /__live/script.js')
write_response(
sock,
200,
{ ['Content-Type'] = 'application/javascript; charset=utf-8' },
CLIENT_JS
)
return
end
local resolved = resolve_path(inst.root_real, path)
if not resolved then
error_response(sock, 404)
return
end
local stat = uv.fs_stat(resolved)
if not stat then
error_response(sock, 404)
return
end
if stat.type == 'directory' then
if path:sub(-1) ~= '/' then
write_response(sock, 301, { ['Location'] = path .. '/' }, '')
return
end
local index = resolved .. '/index.html'
local index_stat = uv.fs_stat(index)
if index_stat and index_stat.type == 'file' then
serve_file_streaming(sock, index)
else
serve_directory_listing(sock, resolved, path, inst.root_real)
end
return
end
serve_file_streaming(sock, resolved)
end
---@param inst live_server.Instance
---@param err? string
local function on_connection(inst, err)
if err then
return
end
local sock = uv.new_tcp()
inst.handle:accept(sock)
local buf = ''
sock:read_start(function(read_err, data)
if read_err or not data then
if not sock:is_closing() then
sock:close()
end
return
end
buf = buf .. data
if buf:find('\r\n\r\n') or buf:find('\n\n') then
sock:read_stop()
handle_request(inst, sock, buf)
end
end)
end
---@param inst live_server.Instance
local function setup_file_watcher(inst)
local fs_event = uv.new_fs_event()
if not fs_event then
return
end
inst.fs_event = fs_event
---@type boolean
local pending_css_only = true
---@param watch_err? string
---@param filename? string
local function on_change(watch_err, filename)
if watch_err then
return
end
dbg(inst, ('fs_event: %s'):format(filename or '<nil>'))
if filename and should_ignore(filename, inst.ignore_patterns) then
dbg(inst, ('fs_event ignored: %s'):format(filename))
return
end
if filename and not filename:match('%.css$') then
pending_css_only = false
end
dbg(
inst,
('fs_event: %s (css_only=%s)'):format(filename or '<nil>', tostring(pending_css_only))
)
inst.debounce_timer:stop()
inst.debounce_timer:start(inst.debounce_ms, 0, function()
local css_only = pending_css_only
pending_css_only = true
dbg(inst, ('debounce fired: css_only=%s'):format(tostring(css_only)))
vim.schedule(function()
S.reload(inst, css_only)
end)
end)
end
local recursive = jit.os ~= 'Linux'
local ok = recursive
and pcall(fs_event.start, fs_event, inst.root_real, { recursive = true }, on_change)
if ok then
dbg(inst, ('watching: %s (recursive=true)'):format(inst.root_real))
else
pcall(fs_event.start, fs_event, inst.root_real, {}, on_change)
dbg(inst, ('watching: %s (recursive=false)'):format(inst.root_real))
if recursive then
return
end
end
end
---@param cfg live_server.StartConfig
---@return live_server.Instance
function S.start(cfg)
local handle = uv.new_tcp()
handle:bind('127.0.0.1', cfg.port)
---@type live_server.Instance
local inst = {
handle = handle,
port = cfg.port,
root_real = cfg.root_real,
sse_clients = {},
debounce_timer = uv.new_timer(),
fs_event = nil,
ignore_patterns = cfg.ignore or {},
debounce_ms = cfg.debounce or 120,
css_inject = cfg.css_inject ~= false,
debug = cfg.debug or false,
}
handle:listen(128, function(listen_err)
on_connection(inst, listen_err)
end)
setup_file_watcher(inst)
return inst
end
---@param inst live_server.Instance
function S.stop(inst)
if inst.debounce_timer then
inst.debounce_timer:stop()
if not inst.debounce_timer:is_closing() then
inst.debounce_timer:close()
end
end
if inst.fs_event then
inst.fs_event:stop()
if not inst.fs_event:is_closing() then
inst.fs_event:close()
end
end
for _, client in ipairs(inst.sse_clients) do
if not client:is_closing() then
pcall(client.close, client)
end
end
inst.sse_clients = {}
if inst.handle and not inst.handle:is_closing() then
inst.handle:close()
end
end
---@param inst live_server.Instance
---@param css_only boolean
function S.reload(inst, css_only)
local use_css = css_only and inst.css_inject
local payload = use_css and '{"css":true}' or '{"css":false}'
dbg(
inst,
('reload: css_only=%s, css_inject=%s, payload=%s'):format(
tostring(css_only),
tostring(inst.css_inject),
payload
)
)
sse_broadcast(inst, 'reload', payload)
end
return S