In this blog post we show you how to compile a small Node.js web server into multi-platform binaries and display content from a external text file on the web page served by this web server.

Overview:

  • Setting up the web server
  • Compile into binary files
  • Display content of a text file

You should understand JavaScript, Node.js and npm. The basics are not explained in this blog post.

Motivation

The resulting applications are

  • standalone: no external dependencies
  • portable: no installation of Node.js or modules
  • simple: only one file to (copy-)deploy
  • independent: application can run no matter the installed Node.js version

Developing the sample application: a simple web server

Preparations

Make sure the correct version of Node.js and npm is installed:

> node --version
v10.16.0
> npm --version
6.9.0

Create a new folder for the server (myServer).

Initialize the project inside the new folder with npm init.

Development dependencies

  • StandardJS for linting.
  • pkg to compile the Node.js application into cross platform binary files.
> npm install --save-dev standard pkg

Production dependencies

> npm install --save fastify

Server

Add a server.js file with the following content:

// Require the framework and instantiate it
const fastify = require('fastify')({ logger: true })
const path = require('path')
const fs = require('fs')

// Declare a route
fastify.get('/', async (request, reply) => {
  return { hello: 'world' }
})

// Run the server!
const start = async () => {
  try {
    await fastify.listen(3000)
    fastify.log.info(`server listening on ${fastify.server.address().port}`)
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

Update package definition

Change the main file in the package.json to be server.js and add a lint script.

The package.json should now look like this:

{
  "name": "myserver",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "lint": "standard",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "pkg": "^4.4.0",
    "standard": "^12.0.1"
  },
  "dependencies": {
    "fastify": "^2.4.1"
  }
}

Run lint and start server

Now we can use npm run lint for linting and npm start to start the server.

If the lint command returns nothing, there was no problem found.

Starting the server should result in the following output:

> npm start

> myserver@1.0.0 start [...]\myServer
> node server.js

{"level":30,"time":[time],"pid":[pid],"hostname":"[hostname]","msg":"Server listening at http://127.0.0.1:3000","v":1}
{"level":30,"time":[time],"pid":[pid],"hostname":"[hostname]","msg":"server listening on 3000","v":1}

Test if the server is working by navigating with your browser to http://127.0.0.1:3000. If you are greeted with „hello world“ the server is working correctly.

Compile the application into binary files

Let’s compile this small web server into binary files for Windows, MacOS and Linux.

First we add a target folder for our binary files: bin.

To compile the Node.js code to binary files we use the npm package pkg. pkg requires an entrypoint to our application, which can be defined inside package.json with the bin property.
While editing the package.json we can also add a script entry which compiles the server. The compile script includes a parameter to specify the output path and a . to provide a entrypoint, this package.json’s bin property.

{
  "name": "myserver",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "bin": "server.js",
  "scripts": {
    "lint": "standard",
    "test": "echo \"Error: no test specified\" && exit 1",
    "compile": "pkg --out-path bin ."
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "pkg": "^4.4.0",
    "standard": "^12.0.1"
  },
  "dependencies": {
    "fastify": "^2.4.1"
  }
}

We can compile the server now with npm run compile and get the following output:

> npm run compile

> myserver@1.0.0 compile [...]\myServer
> pkg --out-path bin .

> pkg@4.4.0
> Targets not specified. Assuming:
  node10-linux-x64, node10-macos-x64, node10-win-x64

Because we did not specify a binary target, pkg is assuming the targets node10-linux-x64, node10-macos-x64 and node10-win-x64, which is fine for our purpose. If you want to specify the target yourself you can use a combination of Node.js version, platform and architecture

Inside the bin folder you can now find three new files:

  • myserver-linux
  • myserver-macos
  • myserver-win.exe

Start the one for your machine’s architecture from terminal / command prompt and the server should be available, compiled as binary file.

The binary files could now be deployed to any machine, but we want to go a step further and display some content from outside the binary file on the homepage.

Display content of a text file

In this last step we want to read a text file while the server is running and be able to edit the text without restarting the server.

Before we can implement this feature it is important to understand how pkg is packing files into the packed binary application. Our server.js entrypoint already contains multiple require statements. pkg will follow those require statements and include any required file into the snapshot filesystem. For our text file we want a different behaviour. We want to be able to change the text after the binaries are created, so the text file can not be included inside the binary files. The current working directory (cwd) of the process can help us in this case.

First create a text file named myText.txt and write some text inside it.

Next, we need to update the „home“ or „/“ route of server.js to read the text file and display it in the „hello world“ response:

[...]

// Declare a route
fastify.get('/', async (request, reply) => {
  let myText
  const cwd = process.cwd()
  const myTextRelativePath = './myText.txt'
  const myTextPath = path.join(cwd, myTextRelativePath)
  myText = fs.readFileSync(myTextPath).toString()

  return {
    hello: 'world',
    myText: myText
  }
})

[...]

This implementation will read myText.txt on every page load, which is not optimal, but sufficient for our purpose, because any change to the text can be seen after a page reload. Notice how process.cwd() is used to get the path of myText.txt. cwd is the way to get outside the snapshot filesystem without providing an absolute path.

With cwd in place, we can now start the server with npm start and should see our text included in the response, before compiling the server again.

{
  "hello": "world",
  "myText": "This text is read from a TXT file."
}

Next we compile the server again with npm run compile. Now it is important to start the server from the project root directory, because myText.txt is loaded relative to the cwd of the terminal / command prompt:

> .\bin\myserver-windows.exe

You can also try to start the binary file from another directory, not including myText.txt. You should receive the following error while browsing the home page:

{
  "statusCode": 500,
  "code": "ENOENT",
  "error": "Internal Server Error",
  "message": "ENOENT: no such file or directory, open '[...]\\myText.txt'"
}

You can also change the text inside myText.txt with the server binary running and after a page reload the change is displayed in the browser.

Conclusion

We created a small web server and compiled it using pkg. To load a file from the real file system (i.e., not the snapshot filesystem) of the machine, we used the current working directory to escape the snapshot file system of pkg.

Although our server example is very simple, the presented behaviour is often used to provide configuration files with a binary for example by changing myText.txt into config.ini and parse the content.