Building Single Executable Applications with 

Node.js

presentation by

Evolution of Application Distribution

The Anatomy of a SEA

Building a File Management Service

Creating a Docker image

A SEA benchmark

Security considerations

 

AGENDA

What is a
Single Executable Application 

Edouard Maleix

 Consultant and 
Trainer
       Node.js | NestJS | Nx

 

getlarge.eu

@getlarge on 

Github

Evolution of Application Distribution

The Moss Strategy

  • Source files are distributed separately
  • Node.js and dependencies must be installed in the target environment
  • The correct version of Node.js and dependencies must be present

The Mangrove Approach

  • All code, dependencies, and assets are bundled together
  • The application carries its own runtime environment
  • The package is self-contained and ready to run

What are the differences?

Self-sufficiency

Resilience

Reliable deployment

The Anatomy of a SEA

Architecture

*.js

*.node

*.json

Application files

Includes JavaScript, JSON, native add-ons and other runtime resources

*.js

*.node

*.json

*.json

Application files

Virtual File System

*.node

*.js

VFS Header

Represents file hierarchy and additional metadata in byte stream

*.js

*.node

*.json

*.json

Application files

Virtual File System

*.node

*.js

VFS Header

Header

Node.js binary

__TEXT

__DATA

__MY_VFS

Includes runtime resources as part of the program

Preserve file hierarchy and related metadata, and enable random
access reads

Virtual File System

Virtual File System

Cross-platform injection of arbitrary data to pre-compiled
executables

Resource Injection

Virtual File System

Take over the entrypoint of Node.js, forward command-line arguments and environment
variables

Bootstraper

Resource Injection

Implementation

class SeaResource {
 public:
  static constexpr uint32_t kMagic = 0x1EA;  // SEA magic number
  static constexpr size_t kHeaderSize = 8;    // Size of header

  SeaFlags flags;
  std::string_view code_path;
  std::string_view main_code_or_snapshot;
  std::optional<std::string_view> code_cache;
  std::unordered_map<std::string_view, std::string_view> assets;
};

SEA Resource structure

ExitCode GenerateSingleExecutableBlob(const SeaConfig& config,
                                     const std::vector<std::string>& args,
                                     const std::vector<std::string>& exec_args) {
  std::string main_script;
  ReadFileSync(&main_script, config.main_path.c_str());

  if (static_cast<bool>(config.flags & SeaFlags::kUseSnapshot)) {
    GenerateSnapshotForSEA(...);
  }

  if (static_cast<bool>(config.flags & SeaFlags::kUseCodeCache)) {
    GenerateCodeCache(config.main_path, main_script);
  }

  std::unordered_map<std::string, std::string> assets;
  BuildAssets(config.assets, &assets);

  SeaSerializer serializer;
  serializer.Write(sea);
}

Build chain

std::string_view FindSingleExecutableBlob() {
#ifdef __APPLE__
  postject_options options;
  postject_options_init(&options);
  options.macho_segment_name = "NODE_SEA";
  const char* blob = static_cast<const char*>(
      postject_find_resource("NODE_SEA_BLOB", &size, &options));
#else
  const char* blob = static_cast<const char*>(
      postject_find_resource("NODE_SEA_BLOB", &size, nullptr));
#endif
  return {blob, size};
}

Resource Injection

void GetAsset(const FunctionCallbackInfo<Value>& args) {
  // Validate input
  CHECK_EQ(args.Length(), 1);
  CHECK(args[0]->IsString());
  
  Utf8Value key(args.GetIsolate(), args[0]);
  SeaResource sea_resource = FindSingleExecutableResource();
  
  auto it = sea_resource.assets.find(*key);
  if (it == sea_resource.assets.end()) {
    return;
  }

  std::unique_ptr<v8::BackingStore> store = ArrayBuffer::NewBackingStore(
      const_cast<char*>(it->second.data()),
      it->second.size(),
      [](void*, size_t, void*) {},  // No-op deleter prevents modifications
      nullptr);
  
  Local<ArrayBuffer> ab = ArrayBuffer::New(args.GetIsolate(), std::move(store));
  args.GetReturnValue().Set(ab);
}

Read-only assets access

MaybeLocal<Value> LoadSingleExecutableApplication(
    const StartExecutionCallbackInfo& info) {
  Local<Context> context = Isolate::GetCurrent()->GetCurrentContext();
  Environment* env = Environment::GetCurrent(context);
  SeaResource sea = FindSingleExecutableResource();

  CHECK(!sea.use_snapshot());
  Local<Value> main_script =
      ToV8Value(env->context(), sea.main_code_or_snapshot).ToLocalChecked();
  return info.run_cjs->Call(
      env->context(), Null(env->isolate()), 1, &main_script);
}

Load the executable

Building a File Management Service

The Application

Project structure

node-sea-demo/
├── src/
│   ├── main.ts                # Entry point with SEA detection
│   ├── app/
│   │   ├── app.ts             # Fastify application setup
│   │   ├── helpers.ts         # Utility functions
│   │   └── routes/
│   │       └── root.ts        # API route handlers
│   └── assets/                # Static files to bundle
├── sea-config.json            # SEA configuration
└── project.json               # Nx build configuration

Initialization

import sea from 'node:sea';

if (sea.isSea()) {
  const { createRequire } = require('node:module');
  require = createRequire(__filename);
}

import Fastify from 'fastify';
import { app } from './app/app';
import { getAssetsDir } from './app/helpers';

const server = Fastify({
  logger: true,
  trustProxy: true,
});

server.register(app);
server.listen({ port: 3000, host: '0.0.0.0' })
  .then(() => {
    console.log(`[ ready ] http://${host}:${port}`);
    console.info(`Assets directory: ${await getAssetsDir()}`);
  })
  .catch(err => {
    server.log.error(err);
    process.exit(1);
  })

Plugins registration

import type { FastifyInstance } from 'fastify';
import fastifyMultipart from '@fastify/multipart';
import sensible from '@fastify/sensible';
import fastifyStatic from '@fastify/static';
import { getAssetsDir } from './helpers';
import routes from './routes/root';

export async function app(fastify: FastifyInstance, opts: AppOptions) {
  await fastify.register(sensible);
  await fastify.register(fastifyMultipart, {
    limits: {
      fileSize: opts.fileSize ?? 1048576 * 10, // 10MB
    },
  });
  const assetsDir = await getAssetsDir();
  await fastify.register(fastifyStatic, {
    root: assetsDir,
    prefix: '/assets/',
  });

  await fastify.register(routes);
}

Path to storage

import path from 'node:path';
import fs from 'node:fs/promises';

export const getAssetsDir = async () => {
  const assetsDir = path.join(__dirname, 'src', 'assets');
  try {
    await fs.access(assetsDir);
  } catch {
    await fs.mkdir(assetsDir, { recursive: true });
  }
  return assetsDir;
};

HTTP API

# upload a file to assets directory
POST http://localhost:3000/upload

# get file metadata from assets directory
GET http://localhost:3000/files/:fileName

# download file from assets directory using static server plugin
GET http://localhost:3000/assets/:fileName

# download file from SEA assets 
GET http://localhost:3000/sea-assets/:fileName

POST /upload

fastify.post('/upload', async (request, reply) => {
    try {
      const data = await request.file();
      if (!data) {
        reply.status(400).send({ error: 'No file provided' });
        return;
      }

      const filePath = safePathResolve(data.filename, dataDir);
      if (!filePath) {
        reply.status(400).send({ error: 'Invalid file path' });
        return;
      }

      await pipeline(data.file, createWriteStream(filePath));

      const manifestPath = path.join(dataDir, 'manifest.json');
      let manifest: { files: Array<{ name: string; timestamp: number }> };

      try {
        const manifestContent = await fs.readFile(manifestPath, 'utf-8');
        manifest = JSON.parse(manifestContent);
      } catch (_err) {
        manifest = { files: [] };
      }

      manifest.files.push({
        name: data.filename,
        timestamp: Date.now(),
      });

      await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));

      reply.send({
        status: 'success',
        file: {
          name: data.filename,
          size: data.file.bytesRead,
        },
      });
    } catch (error) {
      fastify.log.error(error);
      reply.status(500).send({ error: 'Upload failed' });
    }
  });

GET /sea-assets/:fileName

  fastify.get('/sea-assets/:filename', async (request, reply) => {
    if (!sea.isSea()) {
      reply.status(500).send({ error: 'Not in SEA environment' });
      return;
    }
    const { filename } = request.params as { filename: string };
    try {
      const assetBuffer = sea.getAsset(filename);
      reply.send(Buffer.from(assetBuffer).toString());
    } catch (error) {
      if (
        (error as NodeJS.ErrnoException).code ===
        'ERR_SINGLE_EXECUTABLE_APPLICATION_ASSET_NOT_FOUND'
      ) {
        reply.status(404).send({ error: 'File not found' });
      } else {
        fastify.log.error(error);
        reply.status(500).send({ error: 'Internal server error' });
      }
    }
  });

The Build Pipeline

{
  "platform": "node",
  "outputPath": "dist/apps/node-sea-demo",
  "format": ["cjs"],
  "bundle": true,
  "thirdParty": true,
  "main": "apps/node-sea-demo/src/main.ts",
  "tsConfig": "apps/node-sea-demo/tsconfig.app.json",
  "assets": ["apps/node-sea-demo/src/assets/**/*"],
  "generatePackageJson": false,
  "esbuildOptions": {
    "sourcemap": true,
    "outExtension": {
      ".js": ".js"
    }
  }
 }
nx run node-sea-demo:build

ESBuild Configuration

{
  "main": "dist/apps/node-sea-demo/main.js",
  "output": "dist/apps/node-sea-demo/demo.blob",
  "disableExperimentalSEAWarning": true,
  "useCodeCache": true,
  "useSnapshot": false,
  "assets": {
    "package.json": "dist/apps/node-sea-demo/package.json"
  }
}
nx run node-sea-demo:sea-build

Node.js SEA Configuration

node --experimental-sea-config {projectRoot}/sea-config.json

cp $(command -v node) dist/{projectRoot}/node

codesign --remove-signature dist/{projectRoot}/node

npx postject dist/{projectRoot}/node NODE_SEA_BLOB dist/{projectRoot}/demo.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --macho-segment-name NODE_SEA

codesign --sign - dist/{projectRoot}/node

Node.js SEA Commands

# serve the app
nx run node-sea-demo:serve

# build and serve the sea
nx run node-sea-demo:sea-build
./dist/apps/node-sea-demo/node
  
# Upload a file
curl -X POST 'http://localhost:3000/upload' \
  --form 'file=@./package.json'

# List uploaded files
curl -X GET 'http://localhost:3000/files'

# Get file details
curl -X GET 'http://localhost:3000/files/package.json'

# Access static assets
curl -X GET 'http://localhost:3000/assets/test.txt'

# Access SEA assets
curl -X GET 'http://localhost:3000/sea-assets/package.json'

Testing the application

TODO add videos demo

Creating a Docker image

ESBuild bundle size

main.js => 2.3 mb

FROM docker.io/node:lts-alpine

ENV HOST=0.0.0.0
ENV PORT=3000

WORKDIR /app

RUN addgroup --system node-sea-demo && \
  adduser --system -G node-sea-demo node-sea-demo

COPY dist/apps/node-sea-demo node-sea-demo/
RUN chown -R node-sea-demo:node-sea-demo .

# no need for npm install, 
# it's already done when bundling the app esbuild
# RUN npm --prefix node-sea-demo --omit=dev -f install

CMD [ "node", "node-sea-demo" ]

The large

Docker image => 235 mb

FROM docker.io/node:lts AS build

WORKDIR /app
COPY dist/apps/node-sea-demo ./dist/apps/node-sea-demo
COPY apps/node-sea-demo/sea-config.json ./

RUN node --experimental-sea-config ./sea-config.json && \
  cp $(command -v node) dist/apps/node-sea-demo/node \
  && npx postject dist/apps/node-sea-demo/node NODE_SEA_BLOB dist/apps/node-sea-demo/demo.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

FROM docker.io/debian:bullseye-slim

COPY --from=build /app/dist/apps/node-sea-demo/node /app/dist/apps/node-sea-demo/node

ENV HOST=0.0.0.0
ENV PORT=3000

USER node-sea-demo

CMD ["/app/dist/apps/node-sea-demo/node"]

The medium

Docker image => 196 mb

FROM node:lts-alpine AS build

RUN apk add --no-cache build-base python3

WORKDIR /app

RUN wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \
  wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r1/glibc-2.35-r1.apk && \
  apk add --no-cache glibc-2.35-r1.apk && \
  rm glibc-2.35-r1.apk

RUN mkdir -p /app/deps && \
  # Copy all required shared libraries
  ldd dist/apps/node-sea-demo/node | grep "=> /" | awk '{print $3}' | \
  xargs -I '{}' cp -L '{}' /app/deps/ && \
  # Copy additional required files
  cp /lib/ld-linux-*.so.* /app/deps/ && \
  # Create necessary symlinks
  mkdir -p /app/deps/lib64 && \
  cp /lib/ld-linux-*.so.* /app/deps/lib64/

COPY dist/apps/node-sea-demo ./dist/apps/node-sea-demo
COPY apps/node-sea-demo/sea-config.json ./

RUN node --experimental-sea-config ./sea-config.json && \
  cp $(command -v node) dist/apps/node-sea-demo/node && \
  npx postject dist/apps/node-sea-demo/node NODE_SEA_BLOB dist/apps/node-sea-demo/demo.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 && \
  chmod +x dist/apps/node-sea-demo/node && \
  strip dist/apps/node-sea-demo/node
  
FROM scratch

COPY --from=build /app/deps /lib/
COPY --from=build /app/deps/lib64 /lib64/
COPY --from=build /app/dist/apps/node-sea-demo/node /app/node

ENV HOST=0.0.0.0
ENV PORT=3000

CMD ["/app/node"]

The small

Docker image => 156 mb

Docker images size

Docker images size

A SEA benchmark

import { setTimeout } from 'node:timers/promises';

setTimeout(10)
  .then(() => {
    process.exit(0);
  })
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });

Minimal example

import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { parseArgs } from 'node:util';

const __dirname = path.resolve(path.dirname(new URL(import.meta.url).pathname));

const options = {
  app: {
    type: 'string',
    short: 'a',
    default: 'node-sea-perf',
  },
};

const configurations = [
  { useSnapshot: false, useCodeCache: false },
  { useSnapshot: false, useCodeCache: true },
];

const runTest = (config, app) => {
  const appFolder = path.resolve(__dirname, path.join('..', '..', 'apps', app));

  const configName = `sea-${config.useSnapshot ? 'snap' : 'nosnap'}-${
    config.useCodeCache ? 'cache' : 'nocache'
  }`;

  console.log(`Building SEA with configuration: ${configName}`);

  const configJson = JSON.parse(
    fs.readFileSync(path.join(appFolder, 'sea-config.json'), 'utf8')
  );
  configJson.useSnapshot = config.useSnapshot;
  configJson.useCodeCache = config.useCodeCache;
  configJson.output = `dist/apps/${app}/${configName}.blob`;
  fs.writeFileSync(
    path.join(appFolder, 'sea-config.json'),
    JSON.stringify(configJson, null, 2)
  );
  execSync(`npx nx run ${app}:sea-build`);

  const runs = 10;
  const times = [];
  for (let i = 0; i < runs; i++) {
    console.log(`Run ${i + 1}/${runs}`);
    const start = process.hrtime.bigint();
    execSync(`./dist/apps/${app}/node`);
    const end = process.hrtime.bigint();
    times.push(Number(end - start) / 1_000_000);
  }

  const avg = times.reduce((a, b) => a + b, 0) / times.length;
  const min = Math.min(...times);
  const max = Math.max(...times);

  const stats = fs.statSync(`${configJson.output}`);
  return {
    config: configName,
    blobSize: stats.size,
    avgStartupTime: avg,
    minStartupTime: min,
    maxStartupTime: max,
  };
};

const main = () => {
  const { values } = parseArgs({ options });
  const results = [];
  for (const config of configurations) {
    const result = runTest(config, values.app);
    results.push(result);
  }

  console.table(results);
};

main();

Performance testing script

Measurements

node tools/scripts/test-sea.mjs -a node-sea-demo

┌─────────┬──────────────────────┬──────────┬────────────────────┬────────────────┬────────────────┐
│ (index) │ config               │ blobSize │ avgStartupTime     │ minStartupTime │ maxStartupTime │
├─────────┼──────────────────────┼──────────┼────────────────────┼────────────────┼────────────────┤
│ 0       │ 'sea-nosnap-nocache' │ 2432371  │ 216.20656679999996 │ 103.20675      │ 1217.562209    │
│ 1       │ 'sea-nosnap-cache'   │ 2610683  │ 200.02131240000003 │ 87.019417      │ 1197.0825      │
└─────────┴──────────────────────┴──────────┴────────────────────┴────────────────┴────────────────┘
node tools/scripts/test-sea.mjs -a node-sea-perf

┌─────────┬──────────────────────┬──────────┬────────────────────┬────────────────┬────────────────┐
│ (index) │ config               │ blobSize │ avgStartupTime     │ minStartupTime │ maxStartupTime │
├─────────┼──────────────────────┼──────────┼────────────────────┼────────────────┼────────────────┤
│ 0       │ 'sea-nosnap-nocache' │ 277      │ 163.4963915        │ 54.11725       │ 1142.703875    │
│ 1       │ 'sea-nosnap-cache'   │ 1221     │ 164.55824599999997 │ 54.488291      │ 1143.986042    │
└─────────┴──────────────────────┴──────────┴────────────────────┴────────────────┴────────────────┘

Startup time

Startup time

Bundle size

Bundle size

Security considerations

# Examine the Mach-O headers to locate the NODE_SEA segment
otool -l dist/apps/node-sea-demo/node | grep -A 20 NODE_SEA

# Extract the segment using dd, extract from the offset <fileoff> and size <filesize>
dd if=your-sea-app of=extracted.blob bs=1 skip=81739776 count=2432242

# Look for readable strings
strings extracted.blob | less

# For function declarations
strings extracted.blob | grep -A 10 "function" | less

# Targeting assets
strings extracted.blob | grep -A 10 "assets" | less

Source Code Extraction

How can our source code stay safe?

Resources

 

@getlarge on 

Github

Building Single Executable Applications With Node.js

By edouard_maleix

Building Single Executable Applications With Node.js

  • 46