• Blog
  • Talks

Bundling your Node.js web app into a single executable using Bun

2024-11-16

(3 minute read)

In my recent project, Chatfall, I successfully bundled an entire Node.js web app into a single executable using Bun.

A native executable binary is generated for every supported platform that internally contains all of the following:

  • ElysiaJS backend.
  • ReactJS frontend web app.
  • Drizzle ORM migration scripts.
  • Static assets (images, JS, etc) to be served up by the Node.js web server.

Bundling everything into a single file offers several benefits, including:

  • Users don't need to have Node.js pre-installed or to install dependencies.
  • Downloading your app is easier and simpler for users.
  • Distributing your app is simpler.
  • Documentation for getting started is simpler.
  • Docker image construction is easier.

In this post, I will go into the details of how to do this.

I'm using ElysiaJS as the backend. However, you can use any other framework as long as you control the route-handling portion of the HTTP server.

Let's get started.

Static assets

First, we bundle all static assets into the executable to enable runtime loading via static routes. In my Elysia app startup code I have a catch-all route handler:

import { paths as publicPaths } from "./public.generated"
import { renderToReadableStream } from "react-dom/server.browser"
import { App } from "./react/App"

export const app = new Elysia({...})
  .get("/*", async ({ params, request }) => {

    // Serve up static assets
    const p = params["*"]
    if (`/${p}` in publicPaths) {
      return new Response(publicPaths[`/${p}`].data, {
        headers: { "Content-Type": publicPaths[`/${p}`].mimeType },
      })
    }

    // The rest of this code is for rendering the ReactJS app...

    // create our react App component
    const url = new URL(request.url)
    const app = createElement(App, {...})

    // render the app component to a readable stream
    const stream = await renderToReadableStream(app, {
      bootstrapModules: ["/frontend.js"],
      ...
    })

    // output the stream as the response
    return new Response(stream, {
      headers: { "Content-Type": "text/html" },
    })
  })

What we're essentially doing here is bundling all necessary static assets as strings inside public.generated.ts, which is a file that gets generated by our frontend build script. This is a script that must be run before running the dev server and also before building the production version of the server.

The Chatfall version of this script bundles anything that sits inside the ./public folder:

import path from 'path'
import fs from 'fs'

const writePublicFilesToCode = () => {
  const publicDir = path.resolve(__dirname, "../public")
  const outputFile = path.resolve(__dirname, "../src/public.generated.ts")
  const paths = {}

  const getMimeType = (ext) => {
    const mimeTypes = {
      ".html": "text/html",
      ".css": "text/css",
      ".js": "application/javascript",
      ".json": "application/json",
      ".png": "image/png",
      ".jpg": "image/jpeg",
      ".gif": "image/gif",
      ".svg": "image/svg+xml",
      ".ico": "image/x-icon",
    }
    return mimeTypes[ext] || "application/octet-stream"
  }

  const processFile = (file) => {
    const filePath = `${publicDir}/${file}`
    const ext = path.extname(file)
    const mimeType = getMimeType(ext)
    const data = fs.readFileSync(filePath, "utf-8")
    paths[`/${file}`] = { mimeType, data }
  }

  fs.readdirSync(publicDir).forEach(processFile)

  const content = `
export interface PathData {
  mimeType: string;
  data: string;
}


export const paths: Record<string, PathData> = ${JSON.stringify(paths, null, 2)};`
  fs.writeFileSync(outputFile, content)
}

Earlier in the app catch-all route handler we pointed the React server renderer to /frontend.js as the client-side bootstrapping/rehydration script:

// render the app component to a readable stream
const stream = await renderToReadableStream(app, {
   bootstrapModules: ["/frontend.js"],
   ...
})

For this to work all we have to do is build the React app into the file ./public/frontend.js. The frontend bundling script (see above) will ensure that it gets bundled into public.generated.ts so that it can be served by the Elysia server at runtime.

Drizzle migrations

Instead of using drizzle-kit, we perform migrations programmatically. This allows us to bundle the migrations folder into the executable so that we can unbundle it to the filesystem at runtime.

Let's start with the migration script itself:

import fs from "fs"
import path from "path"
import { migrate as drizzleMigrate } from "drizzle-orm/node-postgres/migrator"
import pc from "picocolors"
import tempDir from "temp-dir"
import { migrationData } from "./migration-data.generated"

export const migrate = async () => {
  const migrationsFolder = path.join(
    tempDir,
    `chatfall-migrations-${Date.now()}`,
  )
  fs.mkdirSync(migrationsFolder, { recursive: true })
  console.log(pc.blue(`Temporary migrations folder: ${migrationsFolder}`))

  // Populate the temporary folder with migration data
  for (const [filePath, content] of Object.entries(migrationData)) {
    const fullPath = path.join(migrationsFolder, filePath)
    fs.mkdirSync(path.dirname(fullPath), { recursive: true })
    fs.writeFileSync(fullPath, content as string)
  }

  try {
    await drizzleMigrate(db, { migrationsFolder })
    console.log(pc.green("Migration successful"))
  } catch (err) {
    console.error(pc.red("Migration failed:"), err)
  } finally {
    fs.rmdirSync(migrationsFolder, { recursive: true })
  }
}

As you can see, this script first writes the static files bundled inside migration-data.generated.ts into a temp folder before then pointing the Drizzle migration logic to that folder.

We generate migration-data.generated.ts using a similar approach to the one we used for the web server static assets earlier. Here is the migration bundle script:

import fs from "fs"
import path from "path"

const drizzleDir = path.resolve(__dirname, "../src/db/migrations")
const dbDir = path.resolve(__dirname, "../src/db")
const outputFile = path.resolve(dbDir, "migration-data.generated.ts")

function readDrizzleFiles(): { [key: string]: string } {
  const files: { [key: string]: string } = {}

  // Read meta/_journal.json
  const journalPath = path.join(drizzleDir, "meta", "_journal.json")
  const journalContent = fs.readFileSync(journalPath, "utf-8")
  files["meta/_journal.json"] = journalContent

  // Parse journal to get SQL file names
  const journal = JSON.parse(journalContent)
  const sqlFileNames = journal.entries.map(
    (entry: { tag: string }) => `${entry.tag}.sql`,
  )

  // Read meta/0000_snapshot.json
  const snapshotPath = path.join(drizzleDir, "meta", "0000_snapshot.json")
  files["meta/0000_snapshot.json"] = fs.readFileSync(snapshotPath, "utf-8")

  // Read SQL files
  for (const sqlFileName of sqlFileNames) {
    const filePath = path.join(drizzleDir, sqlFileName)
    if (fs.existsSync(filePath)) {
      files[sqlFileName] = fs.readFileSync(filePath, "utf-8")
    } else {
      console.warn(`Warning: SQL file ${sqlFileName} not found.`)
    }
  }

  return files
}

const migrationData = readDrizzleFiles()

const fileContent = `
// This file is auto-generated. Do not edit manually.
export const migrationData: { [key: string]: string } = ${JSON.stringify(migrationData, null, 2)};
`

fs.writeFileSync(outputFile, fileContent)
console.log(`\nMigration data generated at ${outputFile}`)

This script first loads in the _journal.json file that Drizzle generates and then examines its contents to determine which SQL files to load and bundle into the final output.

We just need to run the bundle generation script every time we create a new migration with drizzle-kit during local development. An easy way to handle this is by incorporating it into the migration command defined in the package.json.

But since the final executable will just launch the Elysia web server, how and when will we run the migration scripts? A user will need to set their database up before they can run the application.

This is where a command-line interface (CLI) comes in.

CLI

Embedding a simple CLI into the executable allows us to run background applications like web servers and one-off scripts.

The Chatfall CLI script looks like this:

import { Command } from "commander"

const program = new Command()

program
  .name("chatfall")
  .description("Chatfall commenting server")
  .version(CHATFALL_VERSION)

program
  .command("server")
  .description("Start the Chatfall server")
  .action(async () => {
    await startServer()
  })

program
  .command("migrate-db")
  .description("Setup and/or upgrade your database to the latest table schema")
  .action(async () => {
    await migrateDb()
    process.exit(0)
  })

// entry point
;(async function main() {
  await program.parseAsync(process.argv)
})().catch((error) => {
  console.error("Error:", error)
  process.exit(1)
})

This script then becomes the entrypoint for the Bun bundling process.

We first bundle everything into a single Javascript file (which itself can be executed by Bun). We then use Bun to compile said Javascript file into native binaries for every supported platform, as seen in the Chatfall bundling script:

import fs from "fs"
import path from "path"
import Bun, { type BunPlugin } from "bun"
import { execa } from "execa"

// generate single .JS file
const result = await Bun.build({
  entrypoints: [path.resolve(__dirname, "../src/index.ts")],
  outdir: path.resolve(__dirname, "../dist"),
  target: "bun",
  sourcemap: "linked",
  minify: true,
})

if (!result.success) {
  console.error("Build failed")
  for (const message of result.logs) {
    console.error(message)
  }
  process.exit(-1)
}

// rename index.* to app-server.*
fs.renameSync(
  path.resolve(__dirname, "../dist/index.js"),
  path.resolve(__dirname, "../dist/app-server.js"),
)
fs.renameSync(
  path.resolve(__dirname, "../dist/index.js.map"),
  path.resolve(__dirname, "../dist/app-server.d.ts"),
)

// target platforms
const platforms = [
  { name: "linux-x64", target: "bun-linux-x64" },
  { name: "linux-arm64", target: "bun-linux-arm64" },
  { name: "macos-x64", target: "bun-darwin-x64" },
  { name: "macos-arm64", target: "bun-darwin-arm64" },
  { name: "windows-x64", target: "bun-win32-x64" },
]

// generate executables for all target platforms
for (const platform of platforms) {
  try {
    const outfile = path.resolve(
      __dirname,
      `../dist-bin/app-${platform.name}`,
    )

    await execa("bun", [
      "build",
      path.resolve(__dirname, "../dist/app-server.js"),
      "--compile",
      "--outfile",
      outfile,
      "--target",
      platform.target,
    ])

    console.log(`Built executable for ${platform.name}`)
  } catch (error) {
    console.error(`Failed to build for ${platform.name}:`, error)
    process.exit(-1)
  }
}

Putting it all together we will end up with something similar to what you see on the Chatfall release page:

Chatfall release executables

Running the web app is now as simple as downloading the binary and executing it in your terminal/shell. And since it's a CLI you can pass in the --help option to see all supported commands:

Usage: app [options] [command]

Your web app

Options:
  -V, --version   output the version number
  -h, --help      display help for command

Commands:
  server          Start the server
  migrate-db      Setup and/or upgrade your database to the latest table schema
  help [command]  display help for command

Building a Docker image is easy - you just have to bundle the executable with the oven/bun base image, as can be seen in the Chatfall Dockerfile.

Possible improvements

It was enjoyable figuring this out, but managing multiple scripts is cumbersome.

Existing desktop app bundlers like Tauri or Electron could work, but they feel too heavyweight for this purpose. There may be some other existing tool for this use case that I’m unaware of. If not I'm tempted to build one.

In any case, for certain apps, bundling like this simplifies distribution and helps users get started quickly.

  • Home
  • Blog
  • Talks
  • Github
  • Linked-in
  • Email
  • RSS
© Ramesh Nair