Bundled Node.js
OpenWork bundles a complete Node.js runtime to ensure MCP servers and CLI tools function reliably across all user environments. This eliminates dependency on system Node.js installations and provides consistent performance across different platforms.
Overview
Why Bundle Node.js?
- Consistency: Ensures all users have the same Node.js version (v20.18.1)
- Reliability: Works on systems without Node.js installed
- Security: Known-good version with security patches
- Performance: Optimized binaries for each platform/architecture
Version Information
const NODE_VERSION = '20.18.1';
Implementation
Source: /Users/nateb/openwork-repo/apps/desktop/src/main/utils/bundled-node.ts
Path Resolution
export function getBundledNodePaths(): BundledNodePaths | null {
if (!app.isPackaged) {
// In development, use system Node
return null;
}
const platform = process.platform; // 'darwin', 'win32', 'linux'
const arch = process.arch; // 'x64', 'arm64'
const isWindows = platform === 'win32';
const ext = isWindows ? '.exe' : '';
const scriptExt = isWindows ? '.cmd' : '';
// Node.js directory is architecture-specific
const nodeDir = path.join(
process.resourcesPath,
'nodejs',
arch // 'x64' or 'arm64' subdirectory
);
const binDir = isWindows ? nodeDir : path.join(nodeDir, 'bin');
return {
nodePath: path.join(binDir, `node${ext}`),
npmPath: path.join(binDir, `npm${scriptExt}`),
npxPath: path.join(binDir, `npx${scriptExt}`),
binDir,
nodeDir,
};
}
Path Structure
resources/
└── nodejs/
├── x64/
│ ├── bin/ # Unix-like systems
│ │ ├── node
│ │ ├── npm
│ │ └── npx
│ └── node.exe # Windows
│ ├── npm.cmd
│ └── npx.cmd
└── arm64/
└── ... (same structure)
Usage Patterns
1. Path Resolution
// Get bundled paths (production only)
const paths = getBundledNodePaths();
if (paths) {
console.log('Node path:', paths.nodePath);
console.log('NPM path:', paths.npmPath);
console.log('Bin directory:', paths.binDir);
}
2. Availability Check
// Check if bundled Node.js is accessible
export function isBundledNodeAvailable(): boolean {
const paths = getBundledNodePaths();
if (!paths) {
return false;
}
return fs.existsSync(paths.nodePath);
}
3. Smart Path Selection
// Get appropriate node binary (bundled or system)
export function getNodePath(): string {
const bundled = getBundledNodePaths();
if (bundled && fs.existsSync(bundled.nodePath)) {
return bundled.nodePath;
}
// Fallback to system node
return 'node';
}
Critical: Environment Configuration
PATH Injection for Spawned Processes
When spawning Node.js processes, the bundled bin directory must be added to PATH:
import { spawn } from 'child_process';
import { getNpxPath, getBundledNodePaths } from '../utils/bundled-node';
// Get bundled paths
const npxPath = getNpxPath();
const bundledPaths = getBundledNodePaths();
// Build environment with bundled node in PATH
let spawnEnv: NodeJS.ProcessEnv = { ...process.env };
if (bundledPaths) {
const delimiter = process.platform === 'win32' ? ';' : ':';
spawnEnv.PATH = `${bundledPaths.binDir}${delimiter}${process.env.PATH || ''}`;
}
// Spawn with modified environment
spawn(npxPath, ['-y', 'some-package@latest'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: spawnEnv,
});
Why PATH Injection is Critical
The npx executable uses a shebang (#!/usr/bin/env node) that looks for node in the PATH. Without injecting the bundled bin directory:
# Without PATH injection
$ npx -y some-package@latest
node: command not found
With PATH injection:
# With PATH injection (bundled bin directory first)
$ npx -y some-package@latest
# Successfully uses bundled node binary
Platform Support
Supported Platforms
| Platform | Architectures | Notes | |———-|—————|——-| | macOS | x64, arm64 | Native Apple Silicon support | | Windows | x64, arm64 | EXE and CMD files | | Linux | x64, arm64 | Standard ELF binaries |
Platform-Specific Considerations
macOS
- Uses standard Unix binaries
- No file extensions for executables
- Supports both Intel and Apple Silicon
Windows
.exeextensions for node, npm, npx.cmdextensions for npm and npx- Case-insensitive file system
Linux
- Standard ELF binaries
- No file extensions
- Standard executable permissions
Build Integration
Download Script
Node.js binaries are downloaded using a dedicated script:
# Download Node.js for all platforms and architectures
node scripts/download-nodejs.cjs
After-Pack Hook
During packaging, the correct binary is copied into the app bundle:
// In scripts/after-pack.cjs
const arch = process.arch;
const platform = process.platform;
// Copy correct binary to resources
const sourceDir = path.join(__dirname, `nodejs-${platform}-${arch}`);
const targetDir = path.join(
process.resourcesPath,
'nodejs',
arch
);
Resource Path Structure
OpenWork.app/Contents/Resources/
├── nodejs/
│ ├── x64/
│ │ ├── bin/
│ │ │ ├── node
│ │ │ ├── npm
│ │ │ └── npx
│ │ └── node.exe
│ │ ├── npm.cmd
│ │ └── npx.cmd
│ └── arm64/
│ └── ... (same structure)
MCP Server Configuration
When generating MCP server configurations, pass the appropriate environment:
const bundledPaths = getBundledNodePaths();
const serverConfig = {
command: bundledPaths?.nodePath || 'node',
args: ['--loader', 'ts-node/esm'],
env: {
...process.env,
// Inject bundled node bin directory
...(bundledPaths && {
NODE_BIN_PATH: bundledPaths.binDir,
PATH: `${bundledPaths.binDir}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH || ''}`
})
}
};
Debugging and Monitoring
Log Node Information
// Useful for debugging configuration
export function logBundledNodeInfo(): void {
const paths = getBundledNodePaths();
if (!paths) {
console.log('[Bundled Node] Development mode - using system Node.js');
return;
}
console.log('[Bundled Node] Configuration:');
console.log(` Platform: ${process.platform}`);
console.log(` Architecture: ${process.arch}`);
console.log(` Node directory: ${paths.nodeDir}`);
console.log(` Node path: ${paths.nodePath}`);
console.log(` Available: ${fs.existsSync(paths.nodePath)}`);
}
Manual Verification
# Check bundled node version
./resources/nodejs/x64/bin/node --version
# Test npm functionality
./resources/nodejs/x64/bin/npm --version
# Test npx functionality
./resources/nodejs/x64/bin/npx create-react-app@latest test-app
Performance Considerations
Memory Usage
- Bundled binaries increase app size (~100MB per architecture)
- Memory usage consistent with system Node.js
- No additional overhead from bundling
Startup Time
- First launch slightly slower due to binary extraction
- Subsequent launches use cached binaries
- No difference from system Node.js performance
Storage Requirements
- ~200MB total for all architectures
- Only one architecture used per installation
- Automatic cleanup during app updates
Error Handling
Fallback Mechanisms
// Graceful fallback to system node
export function getNodePath(): string {
const bundled = getBundledNodePaths();
if (bundled && fs.existsSync(bundled.nodePath)) {
return bundled.nodePath;
}
// Warn if falling back to system node in packaged app
if (app.isPackaged) {
console.warn('[Bundled Node] WARNING: Bundled Node.js not found, falling back to system node');
}
return 'node'; // Fallback to system node
}
Common Issues
- Missing binaries: Check build process and packaging
- Permission issues: Ensure executable permissions on Unix systems
- Path conflicts: Verify PATH injection in spawn calls
- Architecture mismatch: Match bundled binary with system architecture
Security Considerations
Binary Integrity
- Node.js binaries downloaded from official sources
- No modification of original binaries
- Consistent hashing for verification
Attack Surface
- Standard Node.js security model
- No additional attack vectors from bundling
- Same security considerations as system Node.js
The bundled Node.js system ensures reliable operation across all user environments while providing the flexibility to use system Node.js during development. Proper PATH injection is critical for MCP server and CLI tool functionality.