ts-bridge 0.2.3

A TypeScript language-server shim that bridges Neovim's LSP client with tsserver.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# ts-bridge

[![](https://github.com/user-attachments/assets/7ea6bfbb-4031-44c2-9113-7248c0f1addf)](https://github.com/user-attachments/assets/7ea6bfbb-4031-44c2-9113-7248c0f1addf)

`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 `tsc` or
> `tsserver`. 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.

## Prerequisites

- Node.js 18+ with a matching TypeScript/`tsserver` installation discoverable
  via your workspace (local `node_modules` preferred, but global/npm/Nix paths
  are fine too). `ts-bridge` delegates all language intelligence to this
  `tsserver`; 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.).

## Building

You need Rust and Cargo installed. Then clone the repo and run:

```bash
cargo build --release
```

The resulting binary (`target/release/ts-bridge`) can be pointed to from your
Neovim LSP configuration (built-in `vim.lsp.config` or `nvim-lspconfig`).

## Downloading prebuilt binaries

Get the latest release artifact from the [GitHub Releases](https://github.com/chojs23/ts-bridge/releases) page.

## Install script (Linux/macOS)

The install script downloads the latest release archive from GitHub and places
`ts-bridge` in `~/.local/bin` (override with `--install-dir`).

```bash
curl -fsSL https://raw.githubusercontent.com/chojs23/ts-bridge/main/scripts/install.sh | bash
```

If you already cloned the repo:

```bash
./scripts/install.sh
```

To install a specific version:

```bash
./scripts/install.sh --version v0.4.0
```

The 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).

## Install script (Windows PowerShell)

The PowerShell script downloads the Windows release archive and installs
`ts-bridge.exe` into `%LOCALAPPDATA%\Programs\ts-bridge\bin` by default.

```powershell
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:

```powershell
.\scripts\install.ps1
```

To install a specific version:

```powershell
.\scripts\install.ps1 -Version v0.4.0
```

Pass `-InstallDir` to override the destination or `-NoVerify` to skip checksum
verification.

## LSP Feature Progress

- [x] `initialize`/`initialized` handshake & server capabilities
- [x] `textDocument/didOpen` / `didChange` / `didClose` (`updateOpen` bridging)
- [x] Diagnostics pipeline (`geterr`, semantic/syntax/suggestion batching)
- [x] `textDocument/hover` (`quickinfo`)
- [x] `textDocument/definition` (`definitionAndBoundSpan`)
- [x] `textDocument/typeDefinition` (`typeDefinition`)
- [x] `textDocument/references` (`references`)
- [x] `textDocument/completion` (+ `completionItem/resolve`)
- [x] `textDocument/signatureHelp` (`signatureHelp`)
- [x] `textDocument/publishDiagnostics` streaming
- [x] `workspace/didChangeConfiguration`
- [x] `textDocument/documentHighlight`
- [x] `textDocument/codeAction` / `codeAction/resolve` (quick fixes, organize imports; refactors pending)
- [x] `textDocument/rename` / `workspace/applyEdit` (prepare + execute)
- [x] `textDocument/formatting` / on-type formatting
- [x] `textDocument/implementation`
- [x] `workspace/symbol` / `textDocument/documentSymbol`
- [x] Semantic tokens
- [x] Inlay hints
- [ ] Code lens
- [ ] Custom commands / user APIs (organize imports, fix missing imports, etc.)
- [ ] Dual-process (semantic diagnostics server) feature gating _(experimental)_

## Configuration

`ts-bridge` works out of the box. For Neovim 0.11+ (built-in LSP config), use:

```lua
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:

```lua
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

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.

<details>
<summary>How daemon mode works (click to expand)</summary>

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.

```text
             ┌─────────────────────────────────────────────────┐
             │                 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
  `initialize` handshake.
- The daemon selects (or creates) a project entry based on the client’s
  workspace root and reuses the warm `tsserver` for that project.
- Each session keeps its own open document state and diagnostics routing, but
  requests and responses go through the shared `tsserver` process.
- Idle project entries (no active sessions) are evicted after the idle TTL and
their `tsserver` processes are shut down.
</details>

### Neovim (auto-start the daemon)

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:

```lua
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:

```lua
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).

### Running the daemon manually

```bash
ts-bridge daemon --listen 127.0.0.1:7007 # choose your port
```

Optional knobs:

- `--socket /path/to/ts-bridge.sock` (Unix only)
- `--idle-ttl 1800` (seconds) or `--idle-ttl 30m` (suffix `s`, `m`, `h`)
- `--idle-ttl off` to disable idle eviction

Environment variable equivalents (only when running `ts-bridge` without args
with `TS_BRIDGE_DAEMON=1`):

- `TS_BRIDGE_DAEMON=1` to start daemon mode when running `ts-bridge` without args
- `TS_BRIDGE_DAEMON_LISTEN=127.0.0.1:7007`
- `TS_BRIDGE_DAEMON_SOCKET=/path/to/ts-bridge.sock`
- `TS_BRIDGE_DAEMON_IDLE_TTL=30m` (or `off`)

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.

### Neovim (daemon connection)

When connecting to a running daemon, use `vim.lsp.rpc.connect` and register the custom
server config:

```lua
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:

```lua
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`.

### Daemon status request

To inspect the daemon’s current projects and sessions, send the custom LSP
request `ts-bridge/status` from any connected client:

```lua
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.

## Contributing

Every contributions are welcome! Feel free to open issues or submit pull
requests.