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
+3
View File
@@ -0,0 +1,3 @@
[submodule "lsp/nwscript-language-server"]
path = lsp/nwscript-language-server
url = https://github.com/layonara/nwscript-ee-language-server.git
+50 -1
View File
@@ -4674,6 +4674,53 @@
} }
} }
}, },
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-ws-jsonrpc": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/vscode-ws-jsonrpc/-/vscode-ws-jsonrpc-3.5.0.tgz",
"integrity": "sha512-13ZDy7Od4AfEPK2HIfY3DtyRi4FVsvFql1yobVJrpIoHOKGGJpIjVvIJpMxkrHzCZzWlYlg+WEu2hrYkCTvM0Q==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "~8.2.1"
},
"engines": {
"node": ">=20.10.0",
"npm": ">=10.2.3"
}
},
"node_modules/vscode-ws-jsonrpc/node_modules/vscode-jsonrpc": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -4807,7 +4854,9 @@
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-ws-jsonrpc": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
+10
View File
@@ -10,6 +10,7 @@ import dockerRouter from "./routes/docker.js";
import editorRouter from "./routes/editor.js"; import editorRouter from "./routes/editor.js";
import terminalRouter from "./routes/terminal.js"; import terminalRouter from "./routes/terminal.js";
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
@@ -41,6 +42,15 @@ app.get("*path", (_req, res) => {
server.on("upgrade", (request, socket, head) => { server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url || "", `http://${request.headers.host}`); 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\/(.+)$/); const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
if (termMatch) { if (termMatch) {
const sessionId = termMatch[1]; 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;
}
}
+3 -1
View File
@@ -15,7 +15,9 @@
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-ws-jsonrpc": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@@ -1,6 +1,7 @@
import { useRef, useCallback } from "react"; import { useRef, useCallback, useState } from "react";
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react"; import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
import type { editor } from "monaco-editor"; import type { editor } from "monaco-editor";
import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js";
interface MonacoEditorProps { interface MonacoEditorProps {
filePath: string; filePath: string;
@@ -229,10 +230,16 @@ export function MonacoEditor({
onChange, onChange,
}: MonacoEditorProps) { }: MonacoEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null); const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const [monacoRef, setMonacoRef] = useState<typeof import("monaco-editor") | null>(null);
const resolvedLang = language ?? languageFromPath(filePath);
useLspClient(monacoRef);
useLspDocument(editorRef.current, filePath, resolvedLang);
const handleMount: OnMount = useCallback( const handleMount: OnMount = useCallback(
(editorInstance, monaco) => { (editorInstance, monaco) => {
editorRef.current = editorInstance; editorRef.current = editorInstance;
setMonacoRef(monaco as unknown as typeof import("monaco-editor"));
registerNWScript(monaco); registerNWScript(monaco);
defineForgeTheme(monaco); defineForgeTheme(monaco);
monaco.editor.setTheme("forge-dark"); monaco.editor.setTheme("forge-dark");
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
import { getLspClient, type LspStatus } from "../lib/lspClient.js";
import type { editor } from "monaco-editor";
export function useLspClient(monaco: typeof import("monaco-editor") | null) {
const [status, setStatus] = useState<LspStatus>("disconnected");
const connectingRef = useRef(false);
useEffect(() => {
if (!monaco || connectingRef.current) return;
const client = getLspClient();
if (client.status === "ready") {
setStatus("ready");
return;
}
connectingRef.current = true;
const unsub = client.onStatusChange(setStatus);
client.connect(monaco).catch((err) => {
console.error("[LSP] Connection failed:", err);
});
return () => {
unsub();
};
}, [monaco]);
return { lspClient: getLspClient(), status };
}
export function useLspDocument(
editorInstance: editor.IStandaloneCodeEditor | null,
filePath: string,
language: string,
) {
const prevPathRef = useRef<string | null>(null);
useEffect(() => {
if (!editorInstance) return;
const client = getLspClient();
if (client.status !== "ready") return;
const model = editorInstance.getModel();
if (!model) return;
if (prevPathRef.current && prevPathRef.current !== filePath) {
const prevUri = model.uri;
client.notifyDidClose(prevUri);
}
client.notifyDidOpen(model.uri, language, model.getValue());
prevPathRef.current = filePath;
const changeDisposable = model.onDidChangeContent(() => {
client.notifyDidChange(model.uri, model.getValue());
});
return () => {
changeDisposable.dispose();
client.notifyDidClose(model.uri);
};
}, [editorInstance, filePath, language]);
}
+359
View File
@@ -0,0 +1,359 @@
import { toSocket } from "vscode-ws-jsonrpc";
import { createWebSocketConnection } from "vscode-ws-jsonrpc";
import type * as lsp from "vscode-languageserver-protocol";
import type { editor, languages, IDisposable, Uri, MarkerSeverity } from "monaco-editor";
const LSP_TRACE = false;
function trace(...args: unknown[]) {
if (LSP_TRACE) console.debug("[LSP]", ...args);
}
export type LspStatus = "disconnected" | "connecting" | "initializing" | "ready" | "error";
export class LspClient {
private ws: WebSocket | null = null;
private connection: ReturnType<typeof createWebSocketConnection> | null = null;
private disposables: IDisposable[] = [];
private documentVersions = new Map<string, number>();
private statusListeners = new Set<(status: LspStatus) => void>();
private _status: LspStatus = "disconnected";
private monacoInstance: typeof import("monaco-editor") | null = null;
get status() {
return this._status;
}
private setStatus(s: LspStatus) {
this._status = s;
for (const cb of this.statusListeners) cb(s);
}
onStatusChange(cb: (status: LspStatus) => void): () => void {
this.statusListeners.add(cb);
return () => this.statusListeners.delete(cb);
}
async connect(monaco: typeof import("monaco-editor")): Promise<void> {
this.monacoInstance = monaco;
this.setStatus("connecting");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws/lsp`;
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(url);
this.ws = ws;
ws.onopen = async () => {
try {
const socket = toSocket(ws);
this.connection = createWebSocketConnection(socket, {
error: (msg) => console.error("[LSP]", msg),
warn: (msg) => console.warn("[LSP]", msg),
info: (msg) => trace(msg),
log: (msg) => trace(msg),
});
this.setupNotificationHandlers(monaco);
this.connection.listen();
this.setStatus("initializing");
await this.initialize();
this.registerProviders(monaco);
this.setStatus("ready");
resolve();
} catch (err) {
this.setStatus("error");
reject(err);
}
};
ws.onerror = () => {
this.setStatus("error");
reject(new Error("WebSocket connection failed"));
};
ws.onclose = () => {
this.setStatus("disconnected");
};
});
}
private async initialize(): Promise<void> {
if (!this.connection) throw new Error("No connection");
const initParams: lsp.InitializeParams = {
processId: null,
capabilities: {
textDocument: {
completion: {
completionItem: {
snippetSupport: false,
documentationFormat: ["plaintext", "markdown"],
},
},
hover: {
contentFormat: ["plaintext", "markdown"],
},
synchronization: {
didSave: true,
},
publishDiagnostics: {
relatedInformation: true,
},
},
},
rootUri: "file:///workspace",
workspaceFolders: null,
};
trace("→ initialize", initParams);
const result = await this.connection.sendRequest("initialize", initParams);
trace("← initialize", result);
this.connection.sendNotification("initialized", {});
trace("→ initialized");
}
private setupNotificationHandlers(monaco: typeof import("monaco-editor")): void {
if (!this.connection) return;
this.connection.onNotification(
"textDocument/publishDiagnostics",
(params: lsp.PublishDiagnosticsParams) => {
trace("← publishDiagnostics", params);
const uri = monaco.Uri.parse(params.uri);
const model = monaco.editor.getModel(uri);
if (!model) return;
const markers: editor.IMarkerData[] = params.diagnostics.map((d) => ({
severity: this.mapSeverity(monaco, d.severity),
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
message: d.message,
source: d.source,
}));
monaco.editor.setModelMarkers(model, "nwscript-lsp", markers);
},
);
}
private mapSeverity(
monaco: typeof import("monaco-editor"),
severity: lsp.DiagnosticSeverity | undefined,
): MarkerSeverity {
switch (severity) {
case 1:
return monaco.MarkerSeverity.Error;
case 2:
return monaco.MarkerSeverity.Warning;
case 3:
return monaco.MarkerSeverity.Info;
case 4:
return monaco.MarkerSeverity.Hint;
default:
return monaco.MarkerSeverity.Info;
}
}
private registerProviders(monaco: typeof import("monaco-editor")): void {
this.disposables.push(
monaco.languages.registerCompletionItemProvider("nwscript", {
triggerCharacters: ["."],
provideCompletionItems: async (model, position) => {
if (!this.connection) return { suggestions: [] };
const params: lsp.CompletionParams = {
textDocument: { uri: model.uri.toString() },
position: {
line: position.lineNumber - 1,
character: position.column - 1,
},
};
trace("→ completion", params);
const result: lsp.CompletionItem[] | lsp.CompletionList | null =
await this.connection.sendRequest("textDocument/completion", params);
trace("← completion", result);
if (!result) return { suggestions: [] };
const items = Array.isArray(result) ? result : result.items;
const suggestions: languages.CompletionItem[] = items.map((item) => ({
label: item.label,
kind: this.mapCompletionKind(monaco, item.kind),
insertText: item.insertText ?? item.label,
detail: item.detail,
documentation: item.documentation
? typeof item.documentation === "string"
? item.documentation
: { value: item.documentation.value }
: undefined,
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
}));
return { suggestions };
},
}),
);
this.disposables.push(
monaco.languages.registerHoverProvider("nwscript", {
provideHover: async (model, position) => {
if (!this.connection) return null;
const params: lsp.HoverParams = {
textDocument: { uri: model.uri.toString() },
position: {
line: position.lineNumber - 1,
character: position.column - 1,
},
};
trace("→ hover", params);
const result: lsp.Hover | null = await this.connection.sendRequest(
"textDocument/hover",
params,
);
trace("← hover", result);
if (!result?.contents) return null;
const contents = Array.isArray(result.contents)
? result.contents.map((c) =>
typeof c === "string" ? { value: c } : { value: c.value },
)
: typeof result.contents === "string"
? [{ value: result.contents }]
: "value" in result.contents
? [{ value: result.contents.value }]
: [{ value: String(result.contents) }];
return {
contents,
range: result.range
? {
startLineNumber: result.range.start.line + 1,
startColumn: result.range.start.character + 1,
endLineNumber: result.range.end.line + 1,
endColumn: result.range.end.character + 1,
}
: undefined,
};
},
}),
);
}
private mapCompletionKind(
monaco: typeof import("monaco-editor"),
kind: lsp.CompletionItemKind | undefined,
): languages.CompletionItemKind {
const k = monaco.languages.CompletionItemKind;
switch (kind) {
case 1:
return k.Text;
case 2:
return k.Method;
case 3:
return k.Function;
case 4:
return k.Constructor;
case 5:
return k.Field;
case 6:
return k.Variable;
case 7:
return k.Class;
case 8:
return k.Interface;
case 9:
return k.Module;
case 10:
return k.Property;
case 13:
return k.Enum;
case 14:
return k.Keyword;
case 15:
return k.Snippet;
case 21:
return k.Constant;
case 22:
return k.Struct;
default:
return k.Text;
}
}
notifyDidOpen(uri: Uri, languageId: string, text: string): void {
if (!this.connection || this._status !== "ready") return;
this.documentVersions.set(uri.toString(), 1);
const params: lsp.DidOpenTextDocumentParams = {
textDocument: {
uri: uri.toString(),
languageId,
version: 1,
text,
},
};
trace("→ didOpen", params.textDocument.uri);
this.connection.sendNotification("textDocument/didOpen", params);
}
notifyDidChange(uri: Uri, text: string): void {
if (!this.connection || this._status !== "ready") return;
const version = (this.documentVersions.get(uri.toString()) ?? 0) + 1;
this.documentVersions.set(uri.toString(), version);
const params: lsp.DidChangeTextDocumentParams = {
textDocument: { uri: uri.toString(), version },
contentChanges: [{ text }],
};
trace("→ didChange", params.textDocument.uri);
this.connection.sendNotification("textDocument/didChange", params);
}
notifyDidClose(uri: Uri): void {
if (!this.connection || this._status !== "ready") return;
this.documentVersions.delete(uri.toString());
const params: lsp.DidCloseTextDocumentParams = {
textDocument: { uri: uri.toString() },
};
trace("→ didClose", params.textDocument.uri);
this.connection.sendNotification("textDocument/didClose", params);
}
dispose(): void {
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.connection?.dispose();
this.connection = null;
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close();
}
this.ws = null;
this.documentVersions.clear();
this.setStatus("disconnected");
}
}
let globalClient: LspClient | null = null;
export function getLspClient(): LspClient {
if (!globalClient) {
globalClient = new LspClient();
}
return globalClient;
}