In the previous chapter, Server Instance (The Worker), we built a "Supervisor" that manages the health of our language tools. We learned how to hire a worker, check if they are busy, and restart them if they crash.
But there is a missing piece. A "Worker" is just a software abstraction. The actual language tool (like the Python Language Server) is a totally separate computer program running on your machine.
How do we actually send a message from our Node.js application to that separate Python process? We need a telephone line.
Welcome to The Communicator.
Imagine our application is a person speaking JavaScript. The tool we want to use is a separate creature speaking Python (or Rust, or Go).
They cannot talk directly to each other. They run in different memory spaces. To communicate, they must:
Writing code to handle raw data streams and formatting JSON strings manually is messy and prone to bugs.
The LSP Client is the foundational layer of our system. It wraps the complexity of the Operating System's process management and the JSON-RPC protocol.
It handles three critical jobs:
Analogy: If the "Server Instance" (Chapter 3) is the Manager deciding what to say, the "Low-Level Client" is the Telephone. The Manager picks up the phone, but the phone is responsible for turning the voice into electrical signals and sending them through the wire.
In Node.js, a "Child Process" is a command line program launched by your script. We use a library function called spawn to start it.
Every program has three standard "pipes" connected to it:
This is the "grammar" of the conversation. Instead of sending random text, we send a specific JSON structure:
{ "jsonrpc": "2.0", "method": "initialize", "id": 1 }.
The Client handles this formatting automatically using the vscode-jsonrpc library.
This module is used internally by the Server Instance, but let's look at how simple the interface is.
We create the client and provide a "Crash Handler"โa function to run if the line goes dead unexpectedly.
import { createLSPClient } from './LSPClient';
const client = createLSPClient('python-server', (error) => {
console.error("Oh no! The line went dead:", error);
});
We tell the client which program to run. This connects the wires.
// Start the 'pylsp' (Python Language Server) command
await client.start('pylsp', ['--verbose'], {
cwd: '/path/to/project' // Where the project is located
});
console.log("Phone line connected!");
We don't need to format JSON strings. We just pass a JavaScript object.
// Ask for the definition of a symbol
const result = await client.sendRequest('textDocument/definition', {
textDocument: { uri: 'file:///app.py' },
position: { line: 10, character: 5 }
});
console.log("Server answered:", result);
The LSPClient coordinates a flow of data between our application and the external process.
Let's look at LSPClient.ts to see how the magic happens.
This is the most critical part. We use Node's spawn to launch the external tool.
// Inside start() function
process = spawn(command, args, {
// We want to control the pipes programmatically
stdio: ['pipe', 'pipe', 'pipe'],
// Set environment variables
env: { ...subprocessEnv(), ...options?.env },
// Hide the window on Windows
windowsHide: true,
})
Explanation: The stdio: ['pipe', ...] setting is the key. It tells the operating system: "Don't print the output to the screen. Give me a data stream so I can read it."
Spawning isn't instant. We need to ensure the process actually exists before we try to talk to it.
// Wait for the 'spawn' event to fire
await new Promise<void>((resolve, reject) => {
const onSpawn = () => resolve()
const onError = (err) => reject(err)
// Listen for success or failure
spawnedProcess.once('spawn', onSpawn)
spawnedProcess.once('error', onError)
})
Explanation: If we try to write to the pipe before the process is ready, our app will crash. This Promise ensures we wait safely.
Raw pipes just send bytes (0s and 1s). We need the vscode-jsonrpc library to turn those bytes into meaningful messages.
import { StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node'
// Listen to the process's MOUTH (stdout)
const reader = new StreamMessageReader(process.stdout)
// Speak into the process's EAR (stdin)
const writer = new StreamMessageWriter(process.stdin)
// Create the translator
connection = createMessageConnection(reader, writer)
connection.listen()
Explanation: This acts as the translator. It reads the raw data coming from the server, finds the JSON objects, parses them, and hands them to our code.
When we send a request, we first check if the connection is alive.
async function sendRequest(method, params) {
if (!connection) {
throw new Error('Phone line is cut (Client not started)')
}
// The connection library handles the ID matching and JSON formatting
return await connection.sendRequest(method, params)
}
The Communicator must also listen for disaster. If the server process dies, we need to know.
process.on('exit', (code) => {
// If the exit wasn't planned (code 0 means success)
if (code !== 0 && !isStopping) {
const error = new Error(`Server crashed with code ${code}`)
// Call the crash handler provided by the Worker
onCrash?.(error)
}
})
Explanation: This notifies the Server Instance (from Chapter 3) that something went wrong, so the Supervisor can decide whether to restart the worker.
The Low-Level Client (The Communicator) is the nuts and bolts of the operation.
spawn to create the language tool process.At this point, we have a full system:
But there is one final piece. Sometimes the Server wants to talk to us without being asked (like sending a list of errors in the file). How do we handle these unsolicited messages?
Next Chapter: Diagnostic Feedback Loop (The Mailbox)
Generated by Code IQ