diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
deleted file mode 100644
index 83cc1c7..0000000
--- a/.github/workflows/ci.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: ci
-on:
- workflow_call:
- pull_request:
- branches: [main]
- push:
- branches: [main]
-
-jobs:
- changes:
- runs-on: ubuntu-latest
- outputs:
- lua: ${{ steps.changes.outputs.lua }}
- steps:
- - uses: actions/checkout@v4
- - uses: dorny/paths-filter@v3
- id: changes
- with:
- filters: |
- lua:
- - 'lua/**'
- - 'plugin/**'
- - '*.lua'
- - '.luarc.json'
- - 'stylua.toml'
- - 'selene.toml'
-
- lua-format:
- runs-on: ubuntu-latest
- needs: changes
- if: ${{ needs.changes.outputs.lua == 'true' }}
- steps:
- - uses: actions/checkout@v4
- - uses: JohnnyMorganz/stylua-action@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- version: 2.1.0
- args: --check .
-
- lua-lint:
- runs-on: ubuntu-latest
- needs: changes
- if: ${{ needs.changes.outputs.lua == 'true' }}
- steps:
- - uses: actions/checkout@v4
- - uses: NTBBloodbath/selene-action@v1.0.0
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- args: --display-style quiet .
-
- lua-typecheck:
- runs-on: ubuntu-latest
- needs: changes
- if: ${{ needs.changes.outputs.lua == 'true' }}
- steps:
- - uses: actions/checkout@v4
- - uses: mrcjkb/lua-typecheck-action@v0
- with:
- checklevel: Warning
- directories: lua
- configpath: .luarc.json
diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml
index bac492d..aca31a3 100644
--- a/.github/workflows/quality.yaml
+++ b/.github/workflows/quality.yaml
@@ -24,6 +24,7 @@ jobs:
- '*.lua'
- '.luarc.json'
- '*.toml'
+ - 'vim.yaml'
markdown:
- '*.md'
- 'doc/**/*.md'
@@ -35,11 +36,8 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- - uses: JohnnyMorganz/stylua-action@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- version: 2.1.0
- args: --check .
+ - uses: cachix/install-nix-action@v31
+ - run: nix develop --command stylua --check .
lua-lint:
name: Lua Lint Check
@@ -48,11 +46,8 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- - name: Lint with Selene
- uses: NTBBloodbath/selene-action@v1.0.0
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- args: --display-style quiet .
+ - uses: cachix/install-nix-action@v31
+ - run: nix develop --command selene --display-style quiet .
lua-typecheck:
name: Lua Type Check
@@ -75,15 +70,5 @@ jobs:
if: ${{ needs.changes.outputs.markdown == 'true' }}
steps:
- uses: actions/checkout@v4
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
- with:
- version: 8
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "20"
- - name: Install prettier
- run: pnpm add -g prettier@3.1.0
- - name: Check markdown formatting with prettier
- run: prettier --check .
+ - uses: cachix/install-nix-action@v31
+ - run: nix develop --command prettier --check .
diff --git a/.gitignore b/.gitignore
index c70d0ff..2722ae6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ doc/tags
CLAUDE.md
.claude/
node_modules/
+
+.envrc
+.direnv
diff --git a/.luarc.json b/.luarc.json
index 3ccfeda..19558f6 100644
--- a/.luarc.json
+++ b/.luarc.json
@@ -1,5 +1,5 @@
{
- "runtime.version": "Lua 5.1",
+ "runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 10afe3f..fdff634 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,4 +14,4 @@ repos:
hooks:
- id: prettier
name: prettier
- files: \.(md|toml|ya?ml|sh)$
+ files: \.(md|toml|yaml|yml|sh)$
diff --git a/README.md b/README.md
index 75b160f..6aee784 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,12 @@
# live-server.nvim
-Live reload HTML, CSS, and JavaScript files inside Neovim with the power of
-[live-server](https://www.npmjs.com/package/live-server).
+Live reload HTML, CSS, and JavaScript files inside Neovim. No external
+dependencies — the server runs entirely in Lua using Neovim's built-in libuv
+bindings.
+
+## Dependencies
+
+- Neovim >= 0.10
## Installation
@@ -12,12 +17,17 @@ Install with your package manager or via
luarocks install live-server.nvim
```
-## Dependencies
-
-- [live-server](https://www.npmjs.com/package/live-server) (install globally via npm/yarn)
-
## Documentation
```vim
:help live-server.nvim
```
+
+## Known Limitations
+
+- **No recursive file watching on Linux**: libuv's `uv_fs_event` only supports
+ recursive directory watching on macOS and Windows. On Linux (inotify), the
+ `recursive` flag is silently ignored, so only files in the served root
+ directory trigger hot-reload. Files in subdirectories (e.g. `css/style.css`)
+ will not be detected. See
+ [libuv#1778](https://github.com/libuv/libuv/issues/1778).
diff --git a/doc/live-server.txt b/doc/live-server.txt
index ac43291..8fcad84 100644
--- a/doc/live-server.txt
+++ b/doc/live-server.txt
@@ -7,13 +7,13 @@ Homepage:
INTRODUCTION *live-server.nvim*
live-server.nvim automatically reloads HTML, CSS, and JavaScript files in the
-browser via a local development server.
+browser via a local development server. No external dependencies required —
+the server runs entirely in Lua using Neovim's built-in libuv bindings.
===============================================================================
REQUIREMENTS *live-server-requirements*
-- Neovim >= 0.9.0
-- live-server npm package (`npm install -g live-server`)
+- Neovim >= 0.10
===============================================================================
CONFIGURATION *live-server-config*
@@ -22,15 +22,23 @@ CONFIGURATION *live-server-config*
Configure via `vim.g.live_server` before the plugin loads: >lua
vim.g.live_server = {
- args = { '--port=8080', '--no-browser' },
+ port = 8080,
+ browser = false,
}
<
*live_server.Config*
Fields: ~
- {args} (`string[]`) Arguments passed to live-server via |vim.fn.jobstart()|.
- Run `live-server --help` to see available options.
- Default: `{ '--port=5555' }`
+ {port} (`integer?`) Port for the HTTP server. Default: `5500`
+ {browser} (`boolean?`) Auto-open browser on start. Default: `true`
+ {debounce} (`integer?`) Debounce interval in ms for file changes.
+ Default: `120`
+ {ignore} (`string[]?`) Lua patterns for file paths to ignore when
+ watching for changes. Default: `{}`
+ {css_inject} (`boolean?`) Hot-swap CSS without full page reload.
+ Default: `true`
+ {debug} (`boolean?`) Log each step of the hot-reload chain via
+ |vim.notify()| at DEBUG level. Default: `false`
===============================================================================
COMMANDS *live-server-commands*
@@ -113,8 +121,32 @@ Lua keybinding to toggle server: >lua
Configuration with custom port and no auto-open: >lua
vim.g.live_server = {
- args = { '--port=3000', '--no-browser' },
+ port = 3000,
+ browser = false,
}
<
+Ignore node_modules and dotfiles: >lua
+
+ vim.g.live_server = {
+ ignore = { 'node_modules', '^%.' },
+ }
+<
+===============================================================================
+KNOWN LIMITATIONS *live-server-limitations*
+
+No Recursive File Watching on Linux ~
+ *live-server-linux-recursive*
+libuv's `uv_fs_event` only supports recursive directory watching on macOS
+(FSEvents) and Windows (ReadDirectoryChangesW). On Linux, the `recursive`
+flag is silently accepted but ignored because inotify does not support it
+natively. Only files directly in the served root directory trigger
+hot-reload; files in subdirectories (e.g. `css/style.css`) will not be
+detected.
+
+See https://github.com/libuv/libuv/issues/1778
+
+Enable `debug` in |live-server-config| to verify whether `fs_event`
+callbacks fire for a given file change.
+
-------------------------------------------------------------------------------
vim:tw=78:ft=help:norl:
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..b6f667a
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,43 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1771423170,
+ "narHash": "sha256-K7Dg9TQ0mOcAtWTO/FX/FaprtWQ8BmEXTpLIaNRhEwU=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "bcc4a9d9533c033d806a46b37dc444f9b0da49dd",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs",
+ "systems": "systems"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..2753286
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,36 @@
+{
+ description = "live-server.nvim — live reload for web development in Neovim";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ systems.url = "github:nix-systems/default";
+ };
+
+ outputs =
+ {
+ nixpkgs,
+ systems,
+ ...
+ }:
+ let
+ forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
+ in
+ {
+ devShells = forEachSystem (pkgs: {
+ default = pkgs.mkShell {
+ packages = [
+ (pkgs.luajit.withPackages (
+ ps: with ps; [
+ busted
+ nlua
+ ]
+ ))
+ pkgs.prettier
+ pkgs.stylua
+ pkgs.selene
+ pkgs.lua-language-server
+ ];
+ };
+ });
+ };
+}
diff --git a/lua/live-server.lua b/lua/live-server.lua
deleted file mode 100644
index 36b0e97..0000000
--- a/lua/live-server.lua
+++ /dev/null
@@ -1,159 +0,0 @@
----@class live_server.Config
----@field args string[]
-
-local M = {}
-
-local initialized = false
-
----@type table
-local job_cache = {}
-
----@type live_server.Config
-local defaults = {
- args = { '--port=5555' },
-}
-
----@type live_server.Config
-local config = vim.deepcopy(defaults)
-
-local function log(message, level)
- vim.notify(string.format('live-server.nvim: %s', message), vim.log.levels[level])
-end
-
-local function is_windows()
- return vim.loop.os_uname().version:match('Windows')
-end
-
-local function init()
- if initialized then
- return true
- end
-
- local user_config = vim.g.live_server or {}
- config = vim.tbl_deep_extend('force', defaults, user_config)
-
- if
- not vim.fn.executable('live-server')
- and not (is_windows() and vim.fn.executable('live-server.cmd'))
- then
- log('live-server is not executable. Ensure the npm module is properly installed', 'ERROR')
- return false
- end
-
- initialized = true
- return true
-end
-
-local function find_cached_dir(dir)
- if not dir then
- return nil
- end
-
- local cur = dir
- while not job_cache[cur] do
- if cur == '/' or cur:match('^[A-Z]:\\$') then
- return nil
- end
- cur = vim.fn.fnamemodify(cur, ':h')
- end
- return cur
-end
-
-local function is_running(dir)
- local cached_dir = find_cached_dir(dir)
- return cached_dir and job_cache[cached_dir]
-end
-
-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)
- if not init() then
- return
- end
-
- dir = resolve_dir(dir)
-
- if is_running(dir) then
- log('live-server already running', 'INFO')
- return
- end
-
- local cmd_exe = is_windows() and 'live-server.cmd' or 'live-server'
- local cmd = { cmd_exe, dir }
- vim.list_extend(cmd, config.args)
-
- local job_id = vim.fn.jobstart(cmd, {
- on_stderr = function(_, data)
- if not data or data[1] == '' then
- return
- end
- log(data[1]:match('.-m(.-)\27') or data[1], 'ERROR')
- end,
- on_exit = function(_, exit_code)
- job_cache[dir] = nil
- if exit_code == 143 then
- return
- end
- log(string.format('stopped with code %s', exit_code), 'INFO')
- end,
- })
-
- local port = 'unknown'
- for _, arg in ipairs(config.args) do
- local p = arg:match('%-%-port=(%d+)')
- if p then
- port = p
- break
- end
- end
-
- log(string.format('live-server started on 127.0.0.1:%s', port), 'INFO')
- job_cache[dir] = job_id
-end
-
----@param dir? string
-function M.stop(dir)
- dir = resolve_dir(dir)
- local cached_dir = find_cached_dir(dir)
- if cached_dir and job_cache[cached_dir] then
- vim.fn.jobstop(job_cache[cached_dir])
- job_cache[cached_dir] = nil
- log('live-server 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
-
- init()
-end
-
-return M
diff --git a/lua/live-server/health.lua b/lua/live-server/health.lua
new file mode 100644
index 0000000..1225a1a
--- /dev/null
+++ b/lua/live-server/health.lua
@@ -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
diff --git a/lua/live-server/init.lua b/lua/live-server/init.lua
new file mode 100644
index 0000000..3a8ea4b
--- /dev/null
+++ b/lua/live-server/init.lua
@@ -0,0 +1,238 @@
+local server = require('live-server.server')
+
+local M = {}
+
+---@type boolean
+local initialized = false
+
+---@type table
+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
+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
diff --git a/lua/live-server/server.lua b/lua/live-server/server.lua
new file mode 100644
index 0000000..61484d9
--- /dev/null
+++ b/lua/live-server/server.lua
@@ -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
+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
+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 = ''
+
+---@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
+---@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 = ([[%d %s
]]):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('
+Index of %s
+
+%s
+')
+ 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] = '
'):format(prefix, d, d)
+ end
+ for _, f in ipairs(files) do
+ entries[#entries + 1] = ('