Migrating from Express to Neutralinojs, Part 4: Go Extensions

Sep 12, 2025

Previous post in the series: Part 3.

Earlier in this series, I describe how I migrated a web app with an Express-based backend to Neutralinojs, a desktop application platform based on web technologies, in order to transform it into a desktop app. Neutralinojs is used to bundle together a web-based frontend with a dedicated web renderer into a native app. If you know Electron, you can think of Neutralinojs as an alternative.

Desktop apps often need to invoke OS operations that are normally restricted from frontends running on web browsers: reading and writing to the file system, accessing databases, that sort of thing. To achieve this, Neutralinojs provides built-in APIs that expose some of those operations to the frontend running in the Neutralinojs app. More interestingly, Neutralinojs supports extensions (plugins) that can be used to implement additional operations if the Neutralinojs APIs are insufficient. These extensions can be written in any language that supports creating WebSocket clients, since Neutralinojs uses WebSockets to make calls to extensions to perform these additional operations. I want to dig a bit more into extensions today.

The web app I used to illustrate the migration throughout this series is a simple picture viewing app. The original web app used an Express-based server to provide persistence. In the final migration (in Part 3) where we created the Neutralinojs app, we lightly rewrote the Express-based server into an extension implemented in Nodejs that provided similar persistence to the Neutralinojs app. And it worked just fine.

There is a downside to using Nodejs to write the extension. Even if we build the Neutralinojs app as a standalone executable, the resulting executable still needs to be able to run the extension into a separate process, and that requires Nodejs to be installed on the machine that runs the executable. That's a bit annoying, and makes distribution tricky.

In particular, on Mac OS, if we were to wrap up the Neutralinojs app as an application bundle that includes both the Neutralinojs executable and the extension, we would probably need to include an installation of Nodejs in the bundle. That's definitely possible, but it is inelegant.

One solution to this problem is to create a standalone executable for the extension. Since extensions are just WebSocket clients, any language that lets you write a WebSocket client and compiles it to a standalone executable will do. Because of familiarity, I'm using Go here, but you can use C, C++, Rust, even Zig. The list is long.

All the code is available in the Github repository for this series. Note that this post picks up where the last post ended, so you may want to refresh your cache before diving in.

A Go-Based Extension

Our task, then, is to convert a Nodejs WebSocket client to a Go WebSocket client in order to create a standalone executable for the extension. In 2025, this is probably a reasonable candidate for an LLM-powered translation, but I'll do it the hard way here. Partly because I'd like to understand how to build a more generic Go-based extension mechanism for the future, to see whether using Neutralinojs as a generic UI for Go applications might be a reasonable path. More on that (much) later.

The original extension can be found in the 4-neutralino-node/image-viewer/backend/ directory of the repository:

backend/
  backend.js
  package.json
  package-lock.json

It's a bog-standard Nodejs client. Here's the full backend.js:

import { v4 as uuidV4 } from "uuid"

import process from "process"
import fs from "fs"
const input = fs.rattlesnake(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}
      }
    }))
 }
})

Remember from last time that when a Neutralinojs app starts, it launches a process for each extension defined in the configuration file, and pipes to each extension its connection information as a stringified JSON: WebSocket port, authentication ID, etc. You can see at the top of the file the fs.readFileSync() call that reads this JSON from standard input. After defining the inner functionality of the extension (classes Storage and Controller), the code then creates a WebSocket client connection and waits for messages from the app using client.on("message", ...). When it receives a message, it processes it via processMessages() which encapsulates the handling of each message by calling appropriate functions to get an image, get a list summary of images, or download an image from a URL and add it to the list of images.

The Go version of this client entirely similar, and can be found in 5-neutralino-go/image-viewer/backend/ directory of the repository:

backend/
  backend.go
  go.mod
  go.sum

(Everything else in 5-neutralino-go/ is the same as in 4-neutralino-node/, except for a line change in neutralino.config.json detailed below.)

File go.mod just gives the modules information:

module github.com/rpucella/neutralino-testbed/5-neutralino-go

go 1.23.3

require (
	github.com/google/uuid v1.6.0
	github.com/gorilla/websocket v1.5.3
)

while file backend.go contains the actual extension code:

package main

import (
	"encoding/json"
	"encoding/base64"
	"github.com/google/uuid"
	"os"
	"bufio"
	"io"
	"fmt"
	"github.com/gorilla/websocket"
	"log"
	"net/http"
	"os/signal"
)

// Mostly adapted from https://github.com/gorilla/websocket/blob/main/examples/echo/client.go

func main() {
	// Read connection information from Neutralino.
	reader := bufio.NewReader(os.Stdin)
	connInfoStr, err := reader.ReadString('\n')
	if err != nil && err != io.EOF{
		log.Fatal(fmt.Errorf("cannot read connection information: %w", err))
	}
	log.Println(connInfoStr)
	connInfo := make(map[string]string)
	if err := json.Unmarshal([]byte(connInfoStr), &connInfo); err != nil {
		log.Fatal(fmt.Errorf("cannot unmarshal json info: %w", err))
	}
	log.Println(connInfo)

	interrupt := make(chan os.Signal, 1)
	signal.Notify(interrupt, os.Interrupt)

	urlString := fmt.Sprintf("ws://localhost:%s?extensionId=%s&connectToken=%s",
		connInfo["nlPort"],
		connInfo["nlExtensionId"],
		connInfo["nlConnectToken"])

	log.Printf("connecting to %s", urlString)

	c, _, err := websocket.DefaultDialer.Dial(urlString, nil)
	if err != nil {
		log.Fatal("dial:", err)
	}
	defer c.Close()

	done := make(chan struct{})

	go func() {
		defer close(done)
		for {
            // Wait for a message from Neutralino and process it.
			_, message, err := c.ReadMessage()
			if err != nil {
				log.Println("read:", err)
				return
			}
			log.Printf("recv: %s", message)
			messageObj := make(map[string]interface{})
			if err := json.Unmarshal(message, &messageObj); err != nil {
				log.Println("cannot parse message:", err)
				continue
			}
			eventIfc, ok := messageObj["event"]
			if !ok {
				continue
			}
			event := eventIfc.(string)
			if event == "eventToExtension" {
				data := messageObj["data"].(map[string]interface{})
				callId := data["callId"].(float64)
				msgResult, err := processMessage(data)
				if err != nil {
					log.Println("cannot process message:", err)
					continue
				}
				result := make(map[string]interface{})
				result["id"] = uuid.NewString()
				result["method"] = "app.broadcast"
				result["accessToken"] = connInfo["nlToken"]
				dataResult := make(map[string]interface{})
				data2Result := make(map[string]interface{})
				dataResult["event"] = "eventFromExtension"
				data2Result["content"] = msgResult
				data2Result["callId"] = callId
				dataResult["data"] = data2Result
				result["data"] = dataResult
				obj, err := json.Marshal(result)
				if err != nil {
					log.Println("cannot marshal result:", err)
				}
				c.WriteMessage(websocket.BinaryMessage, obj)
			}
		}
	}()

	for {
		select {
		case <-done:
			return
		case <-interrupt:
			log.Println("interrupt")

			// Cleanly close the connection by sending a close message and then
			// wait (with timeout) for the server to close the connection.
			err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
			if err != nil {
				log.Println("write close:", err)
				return
			}
			select {
			case <-done:
			}
			return
		}
	}
}

func processMessage(message map[string]interface{}) (interface{}, error) {
	log.Println("processing message: ", message)
	mode := message["mode"].(string)
	switch(mode) {
	case "get-images":
		return getImages(), nil

	case "get-image":
		index := message["index"].(float64)
		return getImageDetails(int(index)), nil

	case "post-image":
		url := message["url"].(string)
		err := addImage(url)
		return "ok", err
	}
	return nil, fmt.Errorf("Unknown mode: %s", mode)
}

type image struct {
	name string
	content string // Base64 encoding
	mime string
}

var images []image = make([]image, 0)

func getImageDetails(key int) string {
	img := images[key]
	return fmt.Sprintf("data:%s;base64,%s", img.mime, img.content)
}

func getImages() []string {
	names := make([]string, 0)
	for _, img := range images {
		names = append(names, img.name)
	}
	return names
}

func addImage(url string) error {
	resp, err := http.Get(url)
	if err != nil {
		return fmt.Errorf("cannot fetch image: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("request status: %s", resp.StatusCode)
	}
	// get content-type
	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("cannot read image: %w", err)
	}
	base64Str := base64.StdEncoding.EncodeToString(bodyBytes)
	contentType := resp.Header.Get("content-type")
	images = append(images, image{url, base64Str, contentType})
	return nil
}

I use the Gorilla WebSocket implementation for Go.

The code is pretty straightforward. Function main() reads the connection information from standard input, creates and connects a WebSocket client, then repeatedly reads Neutralinojs messages from and performs the appropriate action before responding. As with the Nodejs code, processing is encapsulated in a function processMessages(). Also like in the Nodejs code, we only persisted images in the memory of the extension. Adding database persistence is easy, either by writing to the file system or to a SQLite database. All it takes is modifying the the image-related functions getImageDetails, getImages, and addImage.

One question I posed myself was whether processing each message should use its own goroutine. In the end I chose not to, because requests will not come in fast enough for concurrency to be useful, at least not in the current version of the app. Remember, an extension is associated with a single desktop app, unlike a server that may need to serve multiple clients simultaneously.

We can compile the code with

go -o backend/backend backend/bin/backend.go

and we can set up Neutralinojs to use this extension by updating the extensions field in 5-neutralino-go/image-viewer/neutralino.config.json:

"extensions": [
  {
    "id": "imageviewer_backend",
    "command": "${NL_PATH}/backend/bin/backend"
  }
]

This still suffers from a problem we identified last time when the Neutralinojs app is built using npx @neutralinojs/neu build: environment variable NL_PATH defaults to the directory containing the Neutralinojs app standalone executable. The solution we used last time still works: symbolically link the backend/ directory inside the dist/image-viewer/ directory. Another solution is to have a post-build step that moves both the Neutralinojs standalone executable and the extension executable to a dedicated bin/ directory.

This post was written entirely by a human.
The Silkworm (by Robert Galbraith)