ts-bridge-example.mov
ts-bridge is a standalone TypeScript language-server shim written in Rust. In
this context standalone means the Neovim-facing bits ship as a single Rust
binary—not that TypeScript itself has been rewritten. The binary still launches
the official tsserver that ships with TypeScript and simply orchestrates the
LSP ↔ TypeScript Server conversations.
ts-bridge sits between Neovim's built-in LSP client and tsserver, translating
LSP requests into the TypeScript server protocol (and vice‑versa) while offering
a clear, modular architecture (config, provider, process, protocol,
etc.) that mirrors how modern JS/TS tooling pipelines are organized.
What “standalone” does not mean: This project does not replace
tscortsserver. You still need a standard TypeScript installation, and all type-checking/completions semantics come from Microsoft's compiler. What you gain is a single Rust binary that handles the Neovim side (startup, diagnostics/logging, worker orchestration) without additional Lua or Node glue.
- Node.js 18+ with a matching TypeScript/
tsserverinstallation discoverable via your workspace (localnode_modulespreferred, but global/npm/Nix paths are fine too).ts-bridgedelegates all language intelligence to thistsserver; it only provides the Rust shim and orchestrator. - Neovim 0.11+ so the built-in LSP client matches the capabilities advertised
by
ts-bridge(semantic tokens, inlay hints, etc.).
You need Rust and Cargo installed. Then clone the repo and run:
cargo build --releaseThe resulting binary (target/release/ts-bridge) can be pointed to from your
Neovim LSP configuration (built-in vim.lsp.config or nvim-lspconfig).
Get the latest release artifact from the GitHub Releases page.
If you already have Rust installed, the quickest path is:
cargo install ts-bridge --lockedPin a specific version (for reproducible environments) by passing --version:
cargo install ts-bridge --locked --version 0.2.3The binary will be placed in Cargo's bin directory (typically ~/.cargo/bin); ensure that directory is inside your PATH.
The install script downloads the latest release archive from GitHub and places
ts-bridge in ~/.local/bin (override with --install-dir).
curl -fsSL https://raw.githubusercontent.com/chojs23/ts-bridge/main/scripts/install.sh | bashIf you already cloned the repo:
./scripts/install.shTo install a specific version:
./scripts/install.sh --version v0.4.0The script requires curl or wget plus tar. Checksum verification uses
sha256sum (Linux) or shasum (macOS) when available.
GitHub’s /releases/latest points to the newest non‑pre‑release tag, so you do
not need to create a separate “latest” tag. Use --version to pin a specific
release (including pre‑releases).
The PowerShell script downloads the Windows release archive and installs
ts-bridge.exe into %LOCALAPPDATA%\Programs\ts-bridge\bin by default.
powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-RestMethod -Uri 'https://raw.githubusercontent.com/chojs23/ts-bridge/main/scripts/install.ps1' | Invoke-Expression"If you already cloned the repo:
.\scripts\install.ps1To install a specific version:
.\scripts\install.ps1 -Version v0.4.0Pass -InstallDir to override the destination or -NoVerify to skip checksum
verification.
-
initialize/initializedhandshake & server capabilities -
textDocument/didOpen/didChange/didClose(updateOpenbridging) - Diagnostics pipeline (
geterr, semantic/syntax/suggestion batching) -
textDocument/hover(quickinfo) -
textDocument/definition(definitionAndBoundSpan) -
textDocument/typeDefinition(typeDefinition) -
textDocument/references(references) -
textDocument/completion(+completionItem/resolve) -
textDocument/signatureHelp(signatureHelp) -
textDocument/publishDiagnosticsstreaming -
workspace/didChangeConfiguration -
textDocument/documentHighlight -
textDocument/codeAction/codeAction/resolve(quick fixes, organize imports; refactors pending) -
textDocument/rename/workspace/applyEdit(prepare + execute) -
textDocument/formatting/ on-type formatting -
textDocument/implementation -
workspace/symbol/textDocument/documentSymbol - Semantic tokens
- Inlay hints
- Code lens
- Custom commands / user APIs (organize imports, fix missing imports, etc.)
- Dual-process (semantic diagnostics server) feature gating (experimental)
ts-bridge works out of the box. For Neovim 0.11+ (built-in LSP config), use:
vim.lsp.config("ts_bridge", {
cmd = { "ts-bridge" },
filetypes = { "typescript", "typescriptreact", "javascript", "javascriptreact" },
root_markers = { "tsconfig.json", "jsconfig.json", "package.json", ".git" },
settings = {
["ts-bridge"] = {
separate_diagnostic_server = true, -- launch syntax + semantic tsserver
publish_diagnostic_on = "insert_leave",
enable_inlay_hints = true,
tsserver = {
locale = nil,
log_directory = nil,
log_verbosity = nil,
max_old_space_size = nil,
global_plugins = {},
plugin_probe_dirs = {},
extra_args = {},
preferences = {},
format_options = {},
},
},
},
})
vim.lsp.enable("ts_bridge")tsserver.preferences and tsserver.format_options are forwarded to
tsserver’s configure request (keys are passed through as-is).
If you're using nvim-lspconfig, the equivalent registration is:
local configs = require("lspconfig.configs")
local util = require("lspconfig.util")
if not configs.ts_bridge then
configs.ts_bridge = {
default_config = {
cmd = { "ts-bridge" },
filetypes = { "typescript", "typescriptreact", "javascript", "javascriptreact" },
root_dir = util.root_pattern("tsconfig.json", "jsconfig.json", "package.json", ".git"),
},
}
end
local lspconfig = require("lspconfig")
lspconfig.ts_bridge.setup({
cmd = { "ts-bridge" },
settings = {
["ts-bridge"] = {
separate_diagnostic_server = true, -- launch syntax + semantic tsserver
publish_diagnostic_on = "insert_leave",
enable_inlay_hints = true,
tsserver = {
locale = nil,
log_directory = nil,
log_verbosity = nil,
max_old_space_size = nil,
global_plugins = {},
plugin_probe_dirs = {},
extra_args = {},
preferences = {},
format_options = {},
},
},
},
})If you built locally instead of installing, swap cmd = { "ts-bridge" } for the
absolute binary path (for example, cmd = { "/path/to/ts-bridge" }).
Because ts-bridge delays spawning tsserver until the first routed request,
these defaults (or any overrides you make) apply to both syntax and semantic
processes before they boot. Restart your LSP client after changing the snippet
so a fresh tsserver picks up the new arguments.
Daemon mode keeps a single ts-bridge process alive and reuses warm tsserver
instances across LSP clients. It listens on a TCP address or a Unix socket and
accepts normal LSP JSON-RPC connections.
How daemon mode works (click to expand)
At a high level, the daemon accepts many LSP connections and routes each
project's requests through a shared tsserver service keyed by project root.
┌─────────────────────────────────────────────────┐
│ ts-bridge daemon │
│ │
LSP client 1 ─┤ session (per client) ─┐ │
LSP client 2 ─┤ session (per client) ─┼── Project registry ─┐ │
LSP client 3 ─┤ session (per client) ─┘ │ │
│ │ │
│ project root A ── tsserver (A) │ │
│ project root B ── tsserver (B) │ │
└─────────────────────────────────────────────────┘
Lifecycle summary:
- A client connects over TCP or a Unix socket and completes the normal LSP
initializehandshake. - The daemon selects (or creates) a project entry based on the client’s
workspace root and reuses the warm
tsserverfor that project. - Each session keeps its own open document state and diagnostics routing, but
requests and responses go through the shared
tsserverprocess. - Idle project entries (no active sessions) are evicted after the idle TTL and
their
tsserverprocesses are shut down.
If you prefer not to start the daemon manually, you can spawn it from Neovim and
still connect via vim.lsp.rpc.connect. This runs once per session and reuses
the existing daemon if it is already running:
local function ensure_ts_bridge_daemon()
if vim.g.ts_bridge_daemon_started then
return
end
vim.g.ts_bridge_daemon_started = true
vim.fn.jobstart({
"ts-bridge",
"daemon",
"--listen",
"127.0.0.1:7007", -- choose your port
"--idle-ttl",
"30m",
}, {
detach = true,
env = { RUST_LOG = "info" },
})
end
local function wait_for_daemon(host, port, timeout_ms)
local addr = string.format("%s:%d", host, port)
local function is_ready()
local ok, chan = pcall(vim.fn.sockconnect, "tcp", addr, { rpc = false })
if not ok then
return false
end
if type(chan) == "number" and chan > 0 then
vim.fn.chanclose(chan)
return true
end
return false
end
return vim.wait(timeout_ms, is_ready, 50)
end
local function daemon_cmd(dispatchers)
ensure_ts_bridge_daemon()
-- Built-in LSP has no `on_new_config`, and `before_init` runs after `cmd`, so
-- start + wait here to avoid a first-attach connection refusal.
wait_for_daemon("127.0.0.1", 7007, 2000)
return vim.lsp.rpc.connect("127.0.0.1", 7007)(dispatchers)
end
vim.lsp.config("ts_bridge", {
cmd = daemon_cmd,
filetypes = { "typescript", "typescriptreact", "javascript", "javascriptreact" },
root_markers = { "tsconfig.json", "jsconfig.json", "package.json", ".git" },
})
vim.lsp.enable("ts_bridge")The cmd wrapper ensures the daemon is running before the TCP connection is
attempted (since before_init runs after the transport is created).
If you're using nvim-lspconfig, use:
require("lspconfig").ts_bridge.setup({
cmd = vim.lsp.rpc.connect("127.0.0.1", 7007),
on_new_config = function()
ensure_ts_bridge_daemon()
end,
})Note: cmd_env does not apply when using vim.lsp.rpc.connect, so any daemon
settings and logging must be passed in the jobstart environment or CLI args.
When launching with ts-bridge daemon, TS_BRIDGE_DAEMON_* environment values
are not read; use --idle-ttl (and other flags) or run the env-only daemon mode
(TS_BRIDGE_DAEMON=1 without the daemon subcommand).
ts-bridge daemon --listen 127.0.0.1:7007 # choose your portOptional knobs:
--socket /path/to/ts-bridge.sock(Unix only)--idle-ttl 1800(seconds) or--idle-ttl 30m(suffixs,m,h)--idle-ttl offto disable idle eviction
Environment variable equivalents (only when running ts-bridge without args
with TS_BRIDGE_DAEMON=1):
TS_BRIDGE_DAEMON=1to start daemon mode when runningts-bridgewithout argsTS_BRIDGE_DAEMON_LISTEN=127.0.0.1:7007TS_BRIDGE_DAEMON_SOCKET=/path/to/ts-bridge.sockTS_BRIDGE_DAEMON_IDLE_TTL=30m(oroff)
The default idle TTL is 30 minutes; idle projects (no sessions) are evicted and
their tsserver processes are shut down once they exceed the TTL.
When connecting to a running daemon, use vim.lsp.rpc.connect and register the custom
server config:
vim.lsp.config("ts_bridge", {
cmd = vim.lsp.rpc.connect("127.0.0.1", 7007), -- match daemon address
filetypes = { "typescript", "typescriptreact", "javascript", "javascriptreact" },
root_markers = { "tsconfig.json", "jsconfig.json", "package.json", ".git" },
settings = {
["ts-bridge"] = {
separate_diagnostic_server = true,
publish_diagnostic_on = "insert_leave",
enable_inlay_hints = true,
tsserver = {
global_plugins = {},
plugin_probe_dirs = {},
extra_args = {},
preferences = {},
format_options = {},
},
},
},
})
vim.lsp.enable("ts_bridge")If you're using nvim-lspconfig instead of the built-in config, use:
local configs = require("lspconfig.configs")
local util = require("lspconfig.util")
if not configs.ts_bridge then
configs.ts_bridge = {
default_config = {
cmd = vim.lsp.rpc.connect("127.0.0.1", 7007), -- match daemon address
filetypes = { "typescript", "typescriptreact", "javascript", "javascriptreact" },
root_dir = util.root_pattern("tsconfig.json", "jsconfig.json", "package.json", ".git"),
settings = {
["ts-bridge"] = {
separate_diagnostic_server = true,
publish_diagnostic_on = "insert_leave",
enable_inlay_hints = true,
tsserver = {
global_plugins = {},
plugin_probe_dirs = {},
extra_args = {},
preferences = {},
format_options = {},
},
},
},
},
}
end
require("lspconfig").ts_bridge.setup({})Daemon settings (listen address, idle TTL, etc.) must be configured on the
daemon process itself; they are not part of LSP settings.
To inspect the daemon’s current projects and sessions, send the custom LSP
request ts-bridge/status from any connected client:
vim.lsp.buf_request(0, "ts-bridge/status", {}, function(err, result)
if err then
vim.notify(vim.inspect(err), vim.log.levels.ERROR)
return
end
print(vim.inspect(result))
end)The response includes a projects array with fields such as root,
session_count, session_ids, last_used_epoch_seconds, and tsserver PIDs.
Every contributions are welcome! Feel free to open issues or submit pull requests.