Skip to content

Commit a1d5937

Browse files
charlypaclaude
andcommitted
feat: add pluggable LSP support via NodeConnector
Implement a pluggable LSP infrastructure that allows multiple language servers to be managed through a single NodeConnector. The core handles LSP protocol encoding/decoding while language-specific servers are configured by browser extensions. - Add src-node/lsp-client.js: Core LSP client with multi-server registry - Add TypeScriptSupport extension with code hints provider - Support for typescript-language-server via stdio The architecture allows future language support (Python, Rust, Go, etc.) to be added by creating browser-side extensions that configure their respective servers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8be5b09 commit a1d5937

4 files changed

Lines changed: 751 additions & 0 deletions

File tree

src-node/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const LivePreview = require("./live-preview");
6969
require("./test-connection");
7070
require("./utils");
7171
require("./git/cli");
72+
require("./lsp-client");
7273
function randomNonce(byteLength) {
7374
const randomBuffer = new Uint8Array(byteLength);
7475
crypto.getRandomValues(randomBuffer);

src-node/lsp-client.js

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it
7+
* under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful, but WITHOUT
12+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
14+
* for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
18+
*
19+
*/
20+
21+
/**
22+
* Pluggable LSP Client
23+
*
24+
* This module provides a reusable LSP (Language Server Protocol) client infrastructure
25+
* that supports multiple language servers via a serverId-based registry.
26+
*
27+
* Browser extensions can start language servers by calling:
28+
* execPeer('startServer', { serverId, command, args, rootUri })
29+
*
30+
* And communicate with them via:
31+
* execPeer('sendRequest', { serverId, method, params })
32+
* execPeer('sendNotification', { serverId, method, params })
33+
*
34+
* This design allows future language support (Python, Rust, Go, etc.) to be added
35+
* by simply creating browser-side extensions that configure their respective servers.
36+
*/
37+
38+
const { spawn } = require('child_process');
39+
40+
const CONNECTOR_ID = "ph-lsp"; // Single connector for ALL LSP servers
41+
42+
// Registry of active servers: serverId -> serverState
43+
const servers = new Map();
44+
let nodeConnector = null;
45+
let globalRequestId = 0;
46+
47+
/**
48+
* Encode a JSON-RPC message with LSP Content-Length header
49+
* @param {Object} message - The JSON-RPC message object
50+
* @returns {string} - The encoded message with headers
51+
*/
52+
function encode(message) {
53+
const content = JSON.stringify(message);
54+
return `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n${content}`;
55+
}
56+
57+
/**
58+
* Create a parser for LSP messages from a specific server
59+
* @param {string} serverId - The server identifier
60+
* @returns {Function} - Parser function that processes incoming data chunks
61+
*/
62+
function createParser(serverId) {
63+
let buffer = '';
64+
return (data) => {
65+
buffer += data.toString();
66+
while (true) {
67+
const headerEnd = buffer.indexOf('\r\n\r\n');
68+
if (headerEnd === -1) {
69+
break;
70+
}
71+
72+
const header = buffer.slice(0, headerEnd);
73+
const match = header.match(/Content-Length: (\d+)/);
74+
if (!match) {
75+
break;
76+
}
77+
78+
const length = parseInt(match[1], 10);
79+
const start = headerEnd + 4;
80+
if (buffer.length < start + length) {
81+
break;
82+
}
83+
84+
const json = buffer.slice(start, start + length);
85+
buffer = buffer.slice(start + length);
86+
87+
try {
88+
handleMessage(serverId, JSON.parse(json));
89+
} catch (e) {
90+
console.error(`[${serverId}] parse error:`, e);
91+
}
92+
}
93+
};
94+
}
95+
96+
/**
97+
* Handle an incoming LSP message from a server
98+
* @param {string} serverId - The server identifier
99+
* @param {Object} msg - The parsed JSON-RPC message
100+
*/
101+
function handleMessage(serverId, msg) {
102+
const server = servers.get(serverId);
103+
if (!server) {
104+
return;
105+
}
106+
107+
if (msg.id !== undefined && server.pending.has(msg.id)) {
108+
// This is a response to a request we sent
109+
const { resolve, reject } = server.pending.get(msg.id);
110+
server.pending.delete(msg.id);
111+
if (msg.error) {
112+
reject(msg.error);
113+
} else {
114+
resolve(msg.result);
115+
}
116+
} else if (msg.method) {
117+
// This is a notification or request from the server
118+
nodeConnector.triggerPeer('lspNotification', { serverId, ...msg });
119+
}
120+
}
121+
122+
/**
123+
* Ping endpoint to verify the LSP connector is alive
124+
* @returns {Promise<Object>} - Status and list of active servers
125+
*/
126+
async function ping() {
127+
const activeServers = Array.from(servers.keys());
128+
return { status: "pong", activeServers };
129+
}
130+
131+
/**
132+
* Start a new language server
133+
* @param {Object} params - Server configuration
134+
* @param {string} params.serverId - Unique identifier for this server instance
135+
* @param {string} params.command - The command to spawn the language server
136+
* @param {string[]} [params.args=['--stdio']] - Arguments for the command
137+
* @param {string} params.rootUri - The root URI of the workspace
138+
* @returns {Promise<Object>} - Result with success status and server info
139+
*/
140+
async function startServer(params) {
141+
const { serverId, command, args = ['--stdio'], rootUri } = params;
142+
143+
if (!serverId || !command) {
144+
throw new Error('serverId and command are required');
145+
}
146+
147+
if (servers.has(serverId)) {
148+
return { success: true, message: "already running", serverId };
149+
}
150+
151+
const serverProcess = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
152+
const parser = createParser(serverId);
153+
154+
const serverState = {
155+
process: serverProcess,
156+
pending: new Map(),
157+
rootUri
158+
};
159+
160+
serverProcess.stdout.on('data', parser);
161+
serverProcess.stderr.on('data', (data) => {
162+
console.log(`[${serverId} stderr]`, data.toString());
163+
});
164+
serverProcess.on('exit', (code) => {
165+
servers.delete(serverId);
166+
nodeConnector.triggerPeer('serverExit', { serverId, code });
167+
});
168+
serverProcess.on('error', (err) => {
169+
console.error(`[${serverId}] spawn error:`, err);
170+
servers.delete(serverId);
171+
nodeConnector.triggerPeer('serverError', { serverId, error: err.message });
172+
});
173+
174+
servers.set(serverId, serverState);
175+
console.log(`[lsp-client] Started ${serverId} (pid: ${serverProcess.pid})`);
176+
return { success: true, serverId, pid: serverProcess.pid };
177+
}
178+
179+
/**
180+
* Send an LSP request to a server and wait for response
181+
* @param {Object} params - Request parameters
182+
* @param {string} params.serverId - The target server identifier
183+
* @param {string} params.method - The LSP method name
184+
* @param {Object} params.params - The LSP request parameters
185+
* @returns {Promise<Object>} - The LSP response result
186+
*/
187+
async function sendRequest(params) {
188+
const { serverId, method, params: lspParams } = params;
189+
const server = servers.get(serverId);
190+
191+
if (!server) {
192+
throw new Error(`Server ${serverId} not running`);
193+
}
194+
195+
const id = ++globalRequestId;
196+
const msg = { jsonrpc: '2.0', id, method, params: lspParams };
197+
198+
return new Promise((resolve, reject) => {
199+
server.pending.set(id, { resolve, reject });
200+
server.process.stdin.write(encode(msg));
201+
});
202+
}
203+
204+
/**
205+
* Send an LSP notification to a server (no response expected)
206+
* @param {Object} params - Notification parameters
207+
* @param {string} params.serverId - The target server identifier
208+
* @param {string} params.method - The LSP method name
209+
* @param {Object} params.params - The LSP notification parameters
210+
* @returns {Promise<Object>} - Success confirmation
211+
*/
212+
async function sendNotification(params) {
213+
const { serverId, method, params: lspParams } = params;
214+
const server = servers.get(serverId);
215+
216+
if (!server) {
217+
throw new Error(`Server ${serverId} not running`);
218+
}
219+
220+
const msg = { jsonrpc: '2.0', method, params: lspParams };
221+
server.process.stdin.write(encode(msg));
222+
return { success: true };
223+
}
224+
225+
/**
226+
* Stop a running language server
227+
* @param {Object} params - Stop parameters
228+
* @param {string} params.serverId - The server identifier to stop
229+
* @returns {Promise<Object>} - Success confirmation
230+
*/
231+
async function stopServer(params) {
232+
const { serverId } = params;
233+
const server = servers.get(serverId);
234+
235+
if (server) {
236+
server.process.kill();
237+
servers.delete(serverId);
238+
}
239+
return { success: true, serverId };
240+
}
241+
242+
/**
243+
* List all active language servers
244+
* @returns {Promise<Array>} - Array of server info objects
245+
*/
246+
async function listServers() {
247+
return Array.from(servers.entries()).map(([id, state]) => ({
248+
serverId: id,
249+
pid: state.process.pid,
250+
rootUri: state.rootUri
251+
}));
252+
}
253+
254+
// Create and register the NodeConnector
255+
nodeConnector = global.createNodeConnector(CONNECTOR_ID, {
256+
ping,
257+
startServer,
258+
stopServer,
259+
sendRequest,
260+
sendNotification,
261+
listServers
262+
});
263+
264+
console.log("[lsp-client] Pluggable LSP connector registered");

0 commit comments

Comments
 (0)