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



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





Building Single Executable Applications With Node.js
By edouard_maleix
Building Single Executable Applications With Node.js
- 46