Electron Adventures: Episode 60: Notebook Perl Engine

We did Ruby, we did Python, time for a classic language you probably aren't seeing much of these days - Perl.

But this isn't just a Perl episode. As doing decent session isolation on Perl side would be quite difficult (and to be honest, even our Ruby/Python versions only did fairly limited isolation), we're flipping how things work:

  • previously we had one language server instance, and multiple sessions there
  • now we'll create a new language server instance for every session.

perl_language_server

#!/usr/bin/env perl

use JSON;

sub eval_and_capture {
  my ($code) = @_;

  my $output;
  do {
    local *STDOUT;
    local *STDERR;
    open STDOUT, ">>", \$output;
    open STDERR, ">>", \$output;
    eval($code);
  };
  encode_json({output => $output||"", error => $@});
}

while (<>) {
  my $body = from_json($_);
  my $result = eval_and_capture($body->{code});
  print "$result\n";
  flush STDOUT;
}

This was all surprisingly simple.

Perl's eval already catches exceptions by deafult, to the very intuitively named $@ variable, so we don't need to do any kind of try/catch. It's actually not a bad default.

If you do local *STDOUT in a block, and reopen STDOUT, Perl will automatically restore it when it exits the block. This local trick works for a lot of things like variables, parts of variables, process ENV, and so on, and it's one of the very powerful things in Perl that no other language even tried to copy.

Opening to a reference to a scalar (\$output) redirects output to that scalar. It's that \ character that makes it redirect to $output instead of treating it as a file name.

And like in other language servers, we need to flush the output, so the buffering doesn't get it our way.

The code doesn't do any session management - everything you do will be in its main scope.

src/preload.js

let child_process = require("child_process")
let lineReader = require("promise-readline")
let { contextBridge } = require("electron")

let languageServers = {}

async function startLanguageServer() {
  let process = child_process.spawn(
    "./perl_language_server",
    [],
    {
      stdio: ["pipe", "pipe", "inherit"],
    },
  )
  return {
    process,
    stdin: process.stdin,
    stdout: lineReader(process.stdout),
  }
}

async function runCode(sessionId, code) {
  if (!languageServers[sessionId]) {
    languageServers[sessionId] = await startLanguageServer()
  }
  let { stdin, stdout } = languageServers[sessionId]
  await stdin.write(JSON.stringify({ code }) + "\n")
  let line = await stdout.readLine()
  return JSON.parse(line)
}

contextBridge.exposeInMainWorld(
  "api", { runCode }
)

The necessary change is tiny. Instead of single languageServer variable, it's now a dictionary of connections, keyed by session id.

We definitely could add some logic for closing processes we no longer use, and error handling, but it's fine for now.

Result

I wrote the usual Fibonacci code, and then searched the Internet for the most idiomatic Perl Hello World.

Here's the result if we press "Run All" button:

electron-adventures-60-screenshot.png

In the next episode we'll start a new project.

As usual, all the code for the episode is here.