feat: connect NWScript language server to Monaco via WebSocket

Add the forked nwscript-ee-language-server as a git submodule and wire
it up to the editor through a WebSocket-based LSP bridge:

- Backend: lsp.service.ts spawns the language server in --stdio mode
  and bridges JSON-RPC messages between WebSocket and stdin/stdout
- Backend: /ws/lsp upgrade handler in index.ts
- Frontend: LspClient class using vscode-ws-jsonrpc for JSON-RPC over
  WebSocket, with Monaco providers for completions, hover, and
  diagnostics
- Frontend: useLspClient/useLspDocument hooks integrated into
  MonacoEditor component
This commit is contained in:
plenarius
2026-04-20 19:41:05 -04:00
parent 64908098cd
commit b7177a8fd7
9 changed files with 589 additions and 3 deletions
+10
View File
@@ -10,6 +10,7 @@ import dockerRouter from "./routes/docker.js";
import editorRouter from "./routes/editor.js";
import terminalRouter from "./routes/terminal.js";
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
import { attachLspWebSocket } from "./services/lsp.service.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
@@ -41,6 +42,15 @@ app.get("*path", (_req, res) => {
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url || "", `http://${request.headers.host}`);
if (url.pathname === "/ws/lsp") {
const lspWss = new WebSocketServer({ noServer: true });
lspWss.handleUpgrade(request, socket, head, (ws) => {
attachLspWebSocket(ws);
});
return;
}
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
if (termMatch) {
const sessionId = termMatch[1];
@@ -0,0 +1,89 @@
import { spawn, ChildProcess } from "child_process";
import { WebSocket } from "ws";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let lspProcess: ChildProcess | null = null;
let lspClient: WebSocket | null = null;
function getLspServerPath(): string {
return path.resolve(
__dirname,
"../../../../lsp/nwscript-language-server/server/out/server.js",
);
}
export function startLspServer(): ChildProcess {
if (lspProcess && !lspProcess.killed) {
return lspProcess;
}
const serverPath = getLspServerPath();
lspProcess = spawn("node", [serverPath, "--stdio"], {
env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"],
});
lspProcess.stderr?.on("data", (data: Buffer) => {
console.error(`[LSP stderr] ${data.toString()}`);
});
lspProcess.on("exit", (code) => {
console.log(`[LSP] Language server exited with code ${code}`);
lspProcess = null;
});
return lspProcess;
}
export function attachLspWebSocket(ws: WebSocket): void {
const proc = startLspServer();
lspClient = ws;
ws.on("message", (data) => {
const message = data.toString();
const header = `Content-Length: ${Buffer.byteLength(message)}\r\n\r\n`;
proc.stdin?.write(header + message);
});
let buffer = "";
proc.stdout?.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
while (true) {
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) break;
const header = buffer.substring(0, headerEnd);
const contentLengthMatch = header.match(/Content-Length:\s*(\d+)/i);
if (!contentLengthMatch) {
buffer = buffer.substring(headerEnd + 4);
continue;
}
const contentLength = parseInt(contentLengthMatch[1], 10);
const bodyStart = headerEnd + 4;
if (buffer.length < bodyStart + contentLength) break;
const body = buffer.substring(bodyStart, bodyStart + contentLength);
buffer = buffer.substring(bodyStart + contentLength);
if (ws.readyState === WebSocket.OPEN) {
ws.send(body);
}
}
});
ws.on("close", () => {
lspClient = null;
});
}
export function stopLspServer(): void {
if (lspProcess) {
lspProcess.kill();
lspProcess = null;
}
}