Building a cross-platform background service in Node.js

Syed Andaleeb Roomy
Craftsmen — Software Maestros
4 min readApr 14, 2019

We are living in an era where you can use JavaScript to write almost any part of an application: whether it is the UI that runs in the browser or mobile, or the service/lambda that does the heavy lifting in the cloud, or even beautiful desktop applications like Slack and Visual Studio Code.

In this article, we will discuss how we can build a background service in Node.js that can run across Windows, Linux and macOS.

What is a background service?

A background service (aka daemon) is an executable program that usually starts automatically on boot and keeps running without a UI. For any executable to be a service, it must conform to some rules that the OS expects from a service, like responding to start/stop commands that the OS service runner sends.

Executable? You mean the JavaScript source file?

Not really. We want our service to be a single binary executable without having to rely on the Node.js runtime being installed on the system it will run on. In order to compile this binary, we can use the pkg module. But there is a catch: pkg does not support packaging native addons (.node files) inside the executable. So, as we depend on such a module (os-service, as described below), we will need to manually copy the .node file(s) to the output directory in our build script —

# PLATFORM should be win, linux, or macos
TARGETS="--targets node10-$PLATFORM-x64"
npx pkg . --out-path $OUT_PACKAGE_DIR $TARGETS
cp node_modules/os-service/build/Release/service.node $OUT_PACKAGE_DIR/

It is important to note that the build for each platform needs to be done from that platform, as the native addons are different for each platform.

In the following section, we discuss the code to install, start, stop, and uninstall a service. This code can be part of the single service executable, or a separate tool just for doing these tasks.

How to know which platform you are running on?

Even though some modules take care of platform specific implementation details, there are still situations when you will need to know whether you are running on Windows, Linux, or macOS. The process variable contains the name of the platform, e.g. —

function isWin() {
return process.platform === "win32";
}
function isMac() {
return process.platform === "darwin";
}

Installing the service

os-service is a module that takes care of most of the OS-specific stuff needed to behave as a service.

const service = require ("os-service");service.add(
SERVICE_NAME,
{
displayName,
programPath,
programArgs,
username, // the username to run the service as
password
},
function(error: any) {
if (error) {
reject(error);
} else {
resolve();
}
}
);

Though os-service works nicely for Windows and Linux, it does not handle macOS. For that, we need to save a plist file describing a LaunchDaemon in `/Library/LaunchDaemons/${SERVICE_NAME}.plist`. This is a special XML file containing the service name, path and arguments to the executable. We also need to set the RunAtLoad key to true in order to start it at boot. Correct permissions must be set for macOS to launch the service.

const cp = require('child_process')cp.execSync(`sudo mkdir -p ${path.dirname(filePath)}`);
cp.execSync(`sudo touch ${filePath}`);
cp.execSync(`sudo chown root:wheel ${filePath}`);
cp.execSync(`sudo chmod ${permissions} ${filePath}`);
const command = `sudo sh -c ‘cat > ${filePath}’`;
cp.execSync(command, { input: fileContent });

Sample plist file content:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${SERVICE_NAME}</string>
<key>Program</key>
<string>${EXE_PATH}</string>
<key>ProgramArguments</key>
<array>
<string>${EXE_PATH}</string>
<string>run</string>
<string>--config-file</string>
<string>${DEFAULT_CONFIG_FILE_PATH}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>

Starting the service

Once the service is installed, you can start the service with different platform specific commands like shown here —

if (isWin()) {
cp.execSync(`net start ${SERVICE_NAME}`);
} else if (isMac()) {
cp.execSync(`sudo launchctl load ${LAUNCHD_PLIST_PATH}`);
} else {
cp.execSync(`service ${SERVICE_NAME} start`);
}

Stopping the service

Similar to above —

if (isWin()) {
cp.execSync(`net stop ${SERVICE_NAME}`);
} else if (isMac()) {
cp.execSync(`sudo launchctl unload ${LAUNCHD_PLIST_PATH}`);
} else {
cp.execSync(`service ${SERVICE_NAME} stop`);
}

Uninstalling the service

In macOS, we need to remove the LaunchDaemon plist file. For other platforms, we can just use remove from os-service. Before removing the service, it is better to stop it if it is currently running, as otherwise it can throw an error.

if (isMac()) {
await removeFileAsRoot(LAUNCHD_PLIST_PATH);
resolve();
return;
}
service.remove(SERVICE_NAME,
function(error: any) {
if (error) {
logger.info(`Please make sure to stop the service before removing it`);
reject(error);
} else {
resolve();
}
}
);

That’s it folks! Hope this will help you write your awesome service entirely in Node.js, without writing a single line of code in any other traditional technologies.

--

--