How To Run a Server with Pyodide¶
Pyodide provides a version of the CPython interpreter compiled for WebAssembly, allowing you to execute Python programs either in a web browser or in NodeJS.
This guide outlines how to run your pygls server in such an environment.
Important
This environment imposes some restrictions and limitations to consider. The most obvious restrictions are:
only the STDIO method of communication is supported
threads are unavailable, so your server cannot use the
@server.thread()decoratorwhile it is possible to use async-await syntax in Pyodide, pygls does not currently enable it by default.
The setup is slightly different depending on if you are running your server via the Browser or NodeJs
Using NodeJS¶
The most likely use case for using NodeJS is testing that your server works in Pyodide without requiring the use of a browser testing tool like Selenium. In fact, this is how we test that pygls works correctly when running under Pyodide.
To help illustrate the steps required, we will use pygls’ test suite as an example.
Tip
You can find the complete setup in the tests/pyodide folder of the pygls repository.
Writing our Python code as normal, each server is executed with the help of a wrapper script:
$ node run_server.js /path/to/server.py
The simplest wrapper script might look something like the following
const fs = require('fs');
const { loadPyodide } = require('pyodide');
async function runServer(serverCode) {
// Initialize pyodide.
const pyodide = await loadPyodide()
// Install dependencies
await pyodide.loadPackage("micropip")
const micropip = pyodide.pyimport("micropip")
await micropip.install("pygls")
// Run the server
await pyodide.runPythonAsync(serverCode)
}
if (process.argv.length < 3) {
console.error("Missing server.py file")
process.exit(1)
}
// Read the contents of the given `server.py` file.
const serverCode = fs.readFileSync(process.argv[2], 'utf8')
runServer(serverCode).then(() => {
process.exit(0)
}).catch(err => {
process.exit(1);
})
The above code is assuming that the given Python script ends with a call to your server’s start_io() method.
Redirecting Output¶
Unfortunately, if you tried the above script you will find that your language client wouldn’t be able to establish a connection with the server.
This is due to fact Pyodide will print some log messages to stdout interfering with the client’s communication with the server:
Loading micropip, packaging
Loaded micropip, packaging
Loading attrs, six
Loaded attrs, six
...
To work around this in run_server.js we create a function that will write to a log file.
const consoleLog = console.log
const logFile = fs.createWriteStream("pyodide.log")
function writeToFile(...args) {
logFile.write(args[0] + `\n`);
}
And we use it to temporarily override console.log during startup
async function runServer(serverCode) {
// Annoyingly, while we can redirect stderr/stdout to a file during this setup stage
// it doesn't prevent `micropip.install` from indirectly writing to console.log.
//
// Internally, `micropip.install` calls `pyodide.loadPackage` and doesn't expose loadPackage's
// options for redirecting output i.e. messageCallback.
//
// So instead, we override console.log globally.
console.log = writeToFile
const pyodide = await loadPyodide({
// stdin:
stderr: writeToFile,
})
await pyodide.loadPackage("micropip")
const micropip = pyodide.pyimport("micropip")
await micropip.install("pygls")
// Restore the original console.log
console.log = consoleLog
await pyodide.runPythonAsync(serverCode)
}
While we’re redirecting output, we may as well also pass the writeToFile function to pyodide’s stderr channel.
That way we’re also able to see the server’s logging output while it’s running!
Important
Since node’s fs API is asynchronous, don’t forget to only start the server once the log file has been opened!
logFile.once('open', (fd) => {
runServer(serverCode).then(() => {
logFile.end();
process.exit(0)
}).catch(err => {
logFile.write(`Error in server process\n${err}`)
logFile.end();
process.exit(1);
})
})
Workspace Access¶
At this point we’re able to get a server up and running however, it wouldn’t be able to access any files! There are many ways to approach exposing your files to the server (see the above resources), but for the pygls test suite we copy them into Pyodide’s in-memory filesystem before starting the server.
const path = require('path')
const WORKSPACE = path.join(__dirname, "..", "..", "examples", "servers", "workspace")
function loadWorkspace(pyodide) {
const FS = pyodide.FS
// Create a folder for the workspace to be copied into.
FS.mkdir('/workspace')
const workspace = fs.readdirSync(WORKSPACE)
workspace.forEach((file) => {
try {
const filename = "/" + path.join("workspace", file)
// consoleLog(`${file} -> ${filename}`)
const stream = FS.open(filename, 'w+')
const data = fs.readFileSync(path.join(WORKSPACE, file))
FS.write(stream, data, 0, data.length, 0)
FS.close(stream)
} catch (err) {
consoleLog(err)
}
})
}
async function runServer() {
// ...
loadWorkspace(pyodide)
// ...
}
It’s important to note that this WILL NOT synchronise any changes made within the Pyodide runtime back to the source filesystem, but for the purpose of pygls’ test suite it is sufficient.
It’s also important to note that your language client will need to send URIs that make sense to server’s environment i.e. file:///workspace/sums.txt and not file:///home/username/Projects/pygls/examples/servers/workspace/sums.txt.
Using the Browser¶
See also
- monaco-languageclient GitHub repository
For plenty of examples on how to build an in-browser client on top of the monaco editor
- This commit
For an (outdated!) example on building a simple language client for pygls servers in the browser.
Getting your pygls server to run in a web browser using Pyodide as the runtime is possible. Unfortunately, it is not necessarily easy - mostly because you will most likely have to build your own language client at the same time!
While building an in-browser language client is beyond the scope of this article, we can provide some suggestions to get you started - and if you figure out a nicer way please let us know!
WebWorkers¶
Running your language server in the browser’s main thread is not a great idea since any time your server is processing some message it will block the UI. Instead we can run the server in a WebWorker, which we can think of as the browser’s version of a background thread.
Using the monaco-editor-wrapper project, connecting your server to the client can be as simple as a few lines of configuration
import '@codingame/monaco-vscode-python-default-extension';
import { MonacoEditorLanguageClientWrapper, UserConfig } from 'monaco-editor-wrapper'
export async function run(containerId: string) {
const wrapper = new MonacoEditorLanguageClientWrapper()
const userConfig: UserConfig = {
wrapperConfig: {
editorAppConfig: {
$type: 'extended',
codeResources: {
main: {
text: '1 + 1 =',
uri: '/workspace/sums.txt',
enforceLanguageId: 'plaintext'
}
}
}
},
languageClientConfig: {
languageId: 'plaintext',
options: {
$type: 'WorkerDirect',
worker: new Worker('/run_server.js')
},
}
}
const container = document.getElementById(containerId)
await wrapper.initAndStart(userConfig, container)
}
Where run_server.js is a slightly different version of the wrapper script we used for the NodeJS section above.
Overview¶
See also
Unlike all the other ways you will have run a pygls server up until now, the client and server will not be communicating by reading/writing bytes to/from each other.
Intead they will be passing JSON objects directly using the onmessage event and postMessage functions.
As a result, we will not be calling one of the server’s start_xx methods either, instead we will rely on the events we receive from the client “drive” the server.
Also note that since our server code is running in a WebWorker, we will need to use the importScripts function to pull in the Pyodide library.
importScripts("https://cdn.jsdelivr.net/pyodide/<pyodide_version>/full/pyodide.js");
async function initPyodide() {
// TODO
}
const pyodidePromise = initPyodide()
onmessage = async (event) => {
let pyodide = await pyodidePromise
// TODO
}
By awaiting pyodidePromise in the onmessage, we ensure that Pyodide and all our server code is ready before attempting to handle any messages.
Initializing Pyodide¶
The initPyodide function is fairly similar to the runServer function from the NodeJS example above.
The main differences are
We are now redirecting
stderrtoconsole.lograther than a fileWe are now also redirecting
stdout, parsing the JSON objects being written out and passing them to thepostMessagefunction to send them onto the client.We are not calling
server.start_ioin our server init code.
async function initPyodide() {
console.log("Initializing pyodide.")
/* @ts-ignore */
let pyodide = await loadPyodide({
stderr: console.log
})
console.log("Installing dependencies.")
await pyodide.loadPackage(["micropip"])
await pyodide.runPythonAsync(`
import micropip
await micropip.install('pygls')
`)
// See https://pyodide.org/en/stable/usage/api/js-api.html#pyodide.setStdout
pyodide.setStdout({ batched: (msg) => postMessage(JSON.parse(msg)) })
console.log("Loading server.")
await pyodide.runPythonAsync(`<<insert-your-server-init-code-here>>`)
return pyodide
}
Initializing the Server¶
Since we are not calling the server’s start_io method, we need to configure the server to tell it where to write its messages.
Ideally, this would be done by calling the set_writer() method on the server’s protocol object.
However, at the time of writing there is a bug in Pyodide where output is not flushed correctly, even if you call a method like sys.stdout.flush()
To work around this, we will instead override one of the protocol object’s methods to output the server’s messages as a sequence of newline separated JSON strings.
# Hack to workaround https://github.com/pyodide/pyodide/issues/4139
def send_data(data):
body = json.dumps(data, default=server.protocol._serialize_message)
sys.stdout.write(f"{body}\n")
sys.stdout.flush()
server.protocol._send_data = send_data
The above code snippet should be included along with your server’s init code.
Handling Messages¶
Finally, with the server prepped to send messages, the only thing left to do is to implement the onmessage handler.
const pyodidePromise = initPyodide()
onmessage = async (event) => {
let pyodide = await pyodidePromise
console.log(event.data)
/* @ts-ignore */
self.client_message = JSON.stringify(event.data)
// Run Python synchronously to ensure that messages are processed in the correct order.
pyodide.runPython(`
from js import client_message
message = json.loads(client_message, object_hook=server.protocol.structure_message)
server.protocol.handle_message(message)
`)
}
The above handler
Converts incoming JSON objects to a string and stores them in the
client_messageattribute on the WebWorker itselfOur server code is then able to access the
client_messagevia thejsmodule provided by PyodideThe server parses and handles the given message.