Migrating from Express to Neutralinojs, Part 3

Sep 6, 2025

Previous post in the series: Part 2.

This is the third post in a series about migrating an Express-based web app to Neutralinojs, a desktop application platform based on web technologies. Neutralinojs is a more lightweight alternative to Electron. More interestingly, Neutralinojs lets you write extensions that can access OS resources using any programming language that can access a WebSocket.

I chose to illustrate this migration using a simple picture viewing web app. The code is freely available on GitHub. The full migration is achieved through a sequence of smaller migrations, each isolating one interesting aspect of the whole:

  • Migrate the Express-based web app with multiple HTTP endpoints to an Express-based web app with a single HTTP endpoint.
  • Migrate the Express-based web app with a single HTTP endpoint to an Express-based web app with a WebSocket.
  • Migrate from an Express-based web app with a WebSocket to a Neutralinojs app.

I covered the first two steps in Part 1 and Part 2 of this series. The result of the second step is an Express-based web app that communicates with the backend server via a single WebSocket. You can find the code for that version in directory 3-express-websocket/ of the repository.

In this post, I tackle the final migration step to obtain a Neutralinojs desktop app backed by a Nodejs extension.

Third Migration: Neutralinojs

Neutralinojs is a desktop application platform base on web technologies. What does that mean? Well, it is basically a dedicated web browser into which you can bundle a web frontend (HTML, CSS, Javascript) to create a standalone desktop application that presents the frontend when you run it. And that's it. Of course, the web frontend can form a fully-featured single page web app (SPA) created using any modern web framework (React, Vue, Svelte, dealer's choice). Neutralinojs uses the OS-supplied web renderer (for instance, WebKit) to achieve its lightweightness.

One challenge to the migration of a web app to Neutralinojs is what to when the web app relies on a server to achieve, for instance, persistence via a database, or to access OS-based services? The simple picture viewing app I use as a running example doesn't do persistence, but it's an easy exercise to add it by saving pictures to the file system, or to a SQLite database. Neutralinojs does offer some internal APIs to interact with the local file system, but any other kind of interaction needs to be handled by an extension. (They could have called it a plugin.) An extension is simply an external application that is started when the Neutralinojs app starts, and with which the app can be communicate via an inter-process communication (IPC) mechanism. All operations that in a web app are handled by a server will need to be handled by an extension, if those operations are not already available as a Neutralinojs API.

We therefore have two tasks ahead of us to complete the final step of our migration to Neutralinojs: create the desktop app by suitably modifying our existing frontend code, and transform our existing server into an extension. Easy peasy.

The resulting code can be found in directory 4-neutralino-node/ of the repository:

image-viewer/
  backend/
    backend.js
    package.json
    package-lock.json
  resources/
    icons/
      appIcon.png
    js/
      neutralino.js
    index.html
  neutralino.config.json

Before I describe the content of the directory, it is worth asking how it was created. A Neutralinojs project is created using the neutralinojs/neu npm package. The structure above was created by running the following command in the 4-neutralino-node/ directory:

npx @neutralinojs/neu create image-viewer

This initialized a new Neutralinojs project called image-viewer and put in a placeholder index.html file in image-viewer/resources/. Annoyingly, I had to run the above line multiple time because the initialization often failed. No clue why. Something about failing to download the initial templates. Once the project was initialized, I added the code for the web app and modified it to work with the Neutralinojs platform.

An important file in the project is image-viewer/neutralino.config.json, which configures the project, in roughly the same way that package.json configures an npm project. The default settings are reasonable, and the tweaks I had to make are described below when I talk about extensions.

To run the Neutralinojs app, you first need to build the extension that I describe below. To do so, go into image-viewer/backend/ and run npm install. Once the extension is built, the easiest way to run the Neutralinojs app is to use dev mode, by executing

npx @neutralinojs/neu run

from within the image-viewer/ directory. This will open the desktop app and show the content of image-viewer/resources/index.html. The only way to quit the app, right now, is to hit Ctrl-C from the command line where you started the app. I know: it's rough. You can add functionality in the frontend to quit from a menu option, or something similar, using the Neutralinojs API. I'll leave you to figure that part out, if you care.

Let's look at the actual code. And let's start with the frontend. It lives in image-viewer/resources/. The directory image-viewer/resources/ is taken as the root path for the front end, so any <script src="/foo/bar.js"> link will load image-viewer/resources/foo/bar.js, and similarly for CSS files. This is compatible with the bundle that any web framework can create. Theoretically, you could drop a React-based distribution bundle here and it would work similarly. In fact, that's exactly what I did in the real web app that I migrated and that provided motivation for this series of posts.

I made two changes from the index.html we used in Part 2, both needed to handle the move from a backend server to a Neutralinojs extension. First, I added a line in the frontend to load the Neutralinojs API library:

<script src="/js/neutralino.js"></script>

Second, I modified the _fetch() method in class API to invoke the extension instead of calling a WebSocket endpoint on the server:

class API {
  constructor() {
    this._callId = 0
  }

  async _fetch(_, obj) {
    const extension = "imageviewer_backend"
    const event = "eventToExtension"
    // callId is a unique identifier for the call so that we can catch the response.
    const callId = this._callId
    this._callId += 1
    const response = new Promise((resolve) => {
      const listener = (event) => {
        const data = event.detail
        if (data.callId === callId) {
          Neutralino.events.off('eventFromExtension', listener)
          resolve(data)
        }
      }
      Neutralino.events.on('eventFromExtension', listener)
    })
    await Neutralino.extensions.dispatch(extension, event, {...obj, callId})
    const data = await response
    return data.content
  }

  async fetchImages() {
    return this._fetch("/api/message", {
       "mode": "get-images"
    })
  }

  async fetchImage(index) {
    return this._fetch(`/api/message`, {
      "mode": "get-image",
      "index": index
    })
  }

  async addImage(url) {
    return this._fetch("/api/message", {
      "mode": "post-image",
      "url": url
    })
  }
}

The _fetch() method is very similar to what I had last time, with one big difference: Neutralinojs creates and manages the WebSocket to communicate with the extension and offers an API to send and receive messages over that WebSocket instead of requiring us to use the WebSocket API directly. (The API to communicate with extensions needs the name of the extension with which to communicate, since a Neutralinojs app can use multiple extensions. Every extension gets a name, specified when we create the extension—see below.) For example, a function Neutralino.dispatch() in the Neutralinojs API library sends a message to an extension, and a function Neutralino.events.on() can be used to assign an event listener that listens to messages received from the extension. Aside from this, everything else is the same, including the use of a callId to enforce a request-response protocol over the WebSocket.

The frontend is basically the same. It's a different story for the backend, though again the changes are all in how the backend interfaces with the frontend.

The extension is a partial rewrite of the server. In a standard web app setting, the server is a separate process and is started separately. In a sense, it controls the web app: a browser connects to the server to retrieve the frontend code, and the frontend can then communicate with the server. In a Neutralinojs app, an extension is also a separate process, but it is started and is controlled by the app. How does the app know to start an extension (and how to start it)? That information is added to the neutralino.config.json configuration file. For our code, I added the following field to the configuration file (after enable extensions by setting field enableExtensions to true):

"extensions": [
  {
    "id": "imageviewer_backend",
    "command": "node ${NL_PATH}/backend/backend.js"
  }
]

These lines define an extension called imageviewer_backend that gets run when the Neutralinojs app starts using the command in the command field. The NL_PATH variable is replaced by the root of the Neutralinojs app. The name specified in field id identifies the extension to the frontend, and needs to be supplied to the Neutralinojs API library functions that communicate with extensions.

The extension for this app is in backend/backend.js. Where it lives is largely irrelevant, since you get to specify the location in the extension execution command. The code of the extension is similar to the server code from Part 2. The one difference is that instead of being a server that creates a WebSocket for the frontend to connect to as a client, the Neutralinojs app creates the WebSocket and the extension is the client. How does the extension know the address of the WebSocket to use? It receives the information at start-up time, as a stringified JSON passed via standard input! I can't quite decide for myself if this is clever or silly, and the answer is probably both and it doesn't matter. The JSON object sent by Neutralinojs looks like:

{ 
  nlPort: ...
  nlToken: ...
  nlConnectToken: ...
  nlExtensionId: ...
}

Much of this information is passed into the WebSocket connection URL to authenticate the extension back to the Neutralinojs app, as we'll see in the code below.

I use the ws library for creating the WebSocket client. We of course no longer need Express, since we are not creating a server. But aside from turning the server into a WebSocket client, the rest of the code for processing messages over the WebSocket is exactly the same as in the server case. Here's the code:

import { v4 as uuidV4 } from "uuid"

import process from "process"
import fs from "fs"
const input = fs.readFileSync(process.stdin.fd, 'utf-8')
const processInput = JSON.parse(input)
const NL_PORT = processInput.nlPort
const NL_TOKEN = processInput.nlToken
const NL_CTOKEN = processInput.nlConnectToken
const NL_EXTID = processInput.nlExtensionId
const NL_URL =  `ws://localhost:${NL_PORT}?extensionId=${NL_EXTID}&connectToken=${NL_CTOKEN}`

const images = []

class Storage {
    constructor() {
        this.images = []
    }

    readImage(index) {
        return this.images[index]
    }

    readImageNames() {
        return this.images.map(img => img.name)
    }

    createImage(name, content, contentType) {
        this.images.push({
            name: name,
            content: content,
            mime: contentType
        })
    }
}

class Controller {
    constructor() {
        this.store = new Storage()
    }

    getImageDetails(key) {
        const img = this.store.readImage(key)
        return `data:${img.mime};base64,${img.content}`
    }

    getImages() {
        return this.store.readImageNames()
    }

    async addImage(url) {
        const response = await fetch(url)
        const contentType = response.headers.get('content-type')
        const abuffer = await response.arrayBuffer()
        const base64Image = Buffer.from(abuffer).toString('base64')
        this.store.createImage(url, base64Image, contentType)
    }
}

const controller = new Controller()

async function processMessage(msg) {
    switch(msg.mode) {
    case "get-image":
        return controller.getImageDetails(msg.index)
        break

    case "get-images":
        return controller.getImages()
        break

    case "post-image":
        await controller.addImage(msg.url)
        return "ok"
    }
    console.log(`Error - unknown message type ${msg.mode}`)
    return "unknown message type"
}

// WebSocket client.

import WebSocket from 'ws'
const client = new WebSocket(NL_URL)

client.on('error', (error) => {
    console.log(`Connection error!`)
    console.dir(error, {depth:null})
})
client.on('open', () => console.log("Connected"))
client.on('close', (code, reason) => {
  console.log(`WebSocket closed: ${code} - ${reason}`);
  process.exit()
})
client.on('message', async (evt) => {
  const evtData = evt.toString('utf-8')
  console.log("Event = ", evtData)
  const { event, data } = JSON.parse(evtData)
  if (event === "eventToExtension") {
    const callId = data.callId
    const result = await processMessage(data)
    client.send(JSON.stringify({
      id: uuidV4(),
      method: "app.broadcast",
      accessToken: NL_TOKEN,
      data: {
        event: "eventFromExtension",
        data: {content: result, callId}
      }
    }))
 }
})

Everything above // WebSocket client, the core logic of the backend, is as before, aside from imports and reading the WebSocket information from standard input.

And after this minor rewrite of the frontend and the backend, it works. It took me maybe a week to do the migration for my real web app, basically going through the above path with a few missteps along the way, and quite a bit of banging my head against the wall. The Neutralinojs documentation has some holes into it, and understanding how to interact with extensions requires peering into sample code more than I expected. And debugging WebSocket connection errors is its own dedicated circle of Hell.

I clicked pretty late in the process that the WebSocket set up to communicate between the Neutralinojs desktop app and the extension is basically used as an old-school IPC Unix socket. In many ways, the fact that Neutralinojs communicates with extensions via WebSockets is an implementation detail, hidden underneath the Neutralinojs API. But it made me curious why an IPC socket wasn't actually used for this, instead of a WebSocket. I have no real sense of the efficiency trade-offs between IPC sockets and WebSockets for inter-process communication.

Building a Standalone App

I've highlighted earlier that using npx @neutralinojs/neu run runs a Neutralinojs app in dev mode. This means that the app executes via a pre-installed Neutralinojs executable created in the bin/ directory of the project. That binary, as well as the frontend code, needs to be available to run the app in dev mode.

It is possible to build a standalone executable for the app, using:

npx @neutralinojs/neu build

As usual, run this from the 4-neutralino-node/image-viewer/ directory. This will create a standalone command-line executable in dist/image-viewer/. In fact, it will create multiple executables, for different architectures.

At this point, we can run the appropriate executable from a command line terminal. In my case, running ./dist/image-viewer/image-viewer-mac_arm64 will open up the desktop app. (There is a subtlety when running a Neutralinojs standalone executable. If an extension command uses the NL_PATH environment variable, it may struggle to find the extension. On my system, when running a Neutralinojs standalone executable, NL_PATH is set to the directory that contains the executable, and not the root of the Neutralinojs app project. The easiest fix I could think of that let me run the app both in dev mode and as a standalone executable is to put a symbolic link to the extension directory image-viewer/backend/ in the image-viewer/dist/image-viewer/ directory.)

The standalone executable bundles the frontend code, and can be moved to another directory or moved to another machine for execution. Of course, any extension that the app uses needs to be copied to the target machine as well. And this is where things get a bit more complicated.

First, because the extension of our sample Neutralinojs app uses Nodejs, any target machine needs to have Nodejs installed to run the extension, and therefore the Neutralinojs app. (As you'll notice, we invoke node in the command field of neutralino.config.json for this extension.) There are ways around that, of course. For example, this project aims at bundling Nodejs with the extension by essentially installing a local Nodejs environment alongside the extension. A nicer solution would be to implement the extension in a language that can produce standalone executables, like C, Go, or Rust.

Second, the Neutralinojs app is both a standalone executable for the app alongside code for the extension (either as an executable or as source code, depending on how we implement the extension.) That's a bit of a pain. As is the fact that we need to run the app from the command line, even though it's a desktop application. One solution, on Mac OS, is to package the application into an application bundle which can include both the standalone executable and any auxiliary code or executables required by the application.

I will leave these explorations to future posts. In the meantime, feel free to explore. Happy hacking!

This post was written entirely by a human.
The Cuckoo's Calling (by Robert Galbraith)