tiny-runtime-injector
Version:
A library that helps you download complete, lightweight runtime environments like Node.js, Bun, and uv. It's useful for including minimal runtimes when building applications like Electron.
444 lines (443 loc) • 20.5 kB
JavaScript
import path from "path";
import fs from "fs-extra";
import os from "os";
import { promisify } from "util";
import { exec } from "child_process";
import axios from "axios";
import { createWriteStream } from "fs";
import { pipeline } from "node:stream/promises";
import * as tar from "tar";
import { glob } from "glob";
const execAsync = promisify(exec);
// Default versions for each runtime
const DEFAULT_VERSIONS = {
node: "v22.9.0",
bun: "v1.2.16",
uv: "0.7.13",
};
// Runtime configurations
const RUNTIME_CONFIGS = {
node: {
defaultVersion: DEFAULT_VERSIONS.node,
getDownloadUrl: (version, platform, arch) => {
const platformId = getNodePlatformIdentifier(platform, arch);
const fileExtension = platform === "win32" ? "zip" : "tar.gz";
const fileName = `node-${version}-${platformId}.${fileExtension}`;
return `https://nodejs.org/dist/${version}/${fileName}`;
},
getFileExtension: (platform) => platform === "win32" ? "zip" : "tar.gz",
getExecutablePath: (targetDir, platform) => path.join(targetDir, platform === "win32" ? "node.exe" : "bin/node"),
extractFiles: async (extractedDir, targetDir, version, platform, arch) => {
const platformId = getNodePlatformIdentifier(platform, arch);
const nodeDirName = `node-${version}-${platformId}`;
const extractedNodeDir = path.join(extractedDir, nodeDirName);
const files = await fs.readdir(extractedNodeDir);
for (const file of files) {
const srcPath = path.join(extractedNodeDir, file);
const destPath = path.join(targetDir, file);
await fs.move(srcPath, destPath, { overwrite: true });
}
},
},
bun: {
defaultVersion: DEFAULT_VERSIONS.bun,
getDownloadUrl: (version, platform, arch) => {
const platformId = getBunPlatformIdentifier(platform, arch);
const fileName = `bun-${platformId}.zip`;
return `https://github.com/oven-sh/bun/releases/download/bun-${version}/${fileName}`;
},
getFileExtension: () => "zip",
getExecutablePath: (targetDir, platform) => path.join(targetDir, platform === "win32" ? "bun.exe" : "bun"),
extractFiles: async (extractedDir, targetDir, version, platform, arch) => {
const platformId = getBunPlatformIdentifier(platform, arch);
const bunDirName = `bun-${platformId}`;
const extractedBunDir = path.join(extractedDir, bunDirName);
// Check if bun directory exists, if not, look for the executable directly
let bunExecutable;
if (await fs.pathExists(extractedBunDir)) {
bunExecutable = path.join(extractedBunDir, platform === "win32" ? "bun.exe" : "bun");
}
else {
// Look for bun executable in the root of extracted directory
const files = await fs.readdir(extractedDir);
const execName = platform === "win32" ? "bun.exe" : "bun";
const foundExec = files.find((f) => f === execName);
if (foundExec) {
bunExecutable = path.join(extractedDir, foundExec);
}
else {
throw new Error(`Could not find bun executable in extracted files`);
}
}
const destPath = path.join(targetDir, platform === "win32" ? "bun.exe" : "bun");
await fs.move(bunExecutable, destPath, { overwrite: true });
},
},
uv: {
defaultVersion: DEFAULT_VERSIONS.uv,
getDownloadUrl: (version, platform, arch) => {
const platformId = getUvPlatformIdentifier(platform, arch);
const fileExtension = platform === "win32" ? "zip" : "tar.gz";
const fileName = `uv-${platformId}.${fileExtension}`;
return `https://github.com/astral-sh/uv/releases/download/${version}/${fileName}`;
},
getFileExtension: (platform) => platform === "win32" ? "zip" : "tar.gz",
getExecutablePath: (targetDir, platform) => path.join(targetDir, platform === "win32" ? "uv.exe" : "uv"),
extractFiles: async (extractedDir, targetDir, version, platform, arch) => {
// uv archive contains executables in root or in a subdirectory
const files = await fs.readdir(extractedDir);
const execExtension = platform === "win32" ? ".exe" : "";
const uvExec = `uv${execExtension}`;
const uvxExec = `uvx${execExtension}`;
// Look for executables
const uvPath = files.includes(uvExec)
? path.join(extractedDir, uvExec)
: path.join(extractedDir, files[0], uvExec); // might be in subdirectory
const uvxPath = files.includes(uvxExec)
? path.join(extractedDir, uvxExec)
: path.join(extractedDir, files[0], uvxExec); // might be in subdirectory
// Copy both uv and uvx executables
if (await fs.pathExists(uvPath)) {
await fs.move(uvPath, path.join(targetDir, uvExec), {
overwrite: true,
});
}
if (await fs.pathExists(uvxPath)) {
await fs.move(uvxPath, path.join(targetDir, uvxExec), {
overwrite: true,
});
}
},
},
};
function getNodePlatformIdentifier(platform, arch) {
const archStr = String(arch);
if (platform === "darwin") {
return archStr === "arm64" ? "darwin-arm64" : "darwin-x64";
}
else if (platform === "linux") {
if (archStr === "arm64")
return "linux-arm64";
if (archStr === "arm" || archStr === "armv7l")
return "linux-armv7l";
if (archStr === "ppc64" || archStr === "ppc64le")
return "linux-ppc64le";
if (archStr === "s390" || archStr === "s390x")
return "linux-s390x";
return "linux-x64";
}
else if (platform === "win32") {
if (archStr === "arm64")
return "win-arm64";
if (archStr === "ia32" || archStr === "x86")
return "win-x86";
return "win-x64";
}
throw new Error(`Unsupported platform: ${platform}-${archStr}`);
}
function getBunPlatformIdentifier(platform, arch) {
// Bun uses platform-arch naming
if (platform === "darwin") {
return arch === "arm64" ? "darwin-aarch64" : "darwin-x64";
}
else if (platform === "linux") {
return arch === "arm64" ? "linux-aarch64" : "linux-x64";
}
else if (platform === "win32") {
return arch === "arm64" ? "windows-aarch64" : "windows-x64";
}
throw new Error(`Unsupported platform for Bun: ${platform}-${arch}`);
}
function getUvPlatformIdentifier(platform, arch) {
if (platform === "darwin") {
return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
}
else if (platform === "linux") {
// Standard Linux builds
if (arch === "arm64")
return "aarch64-unknown-linux-gnu";
if (arch === "x64" || arch === "x86_64")
return "x86_64-unknown-linux-gnu";
if (arch === "x86" || arch === "ia32")
return "i686-unknown-linux-gnu";
if (arch === "arm" || arch === "armv7l")
return "armv7-unknown-linux-gnueabihf";
if (arch === "ppc64")
return "powerpc64-unknown-linux-gnu";
if (arch === "ppc64le")
return "powerpc64le-unknown-linux-gnu";
if (arch === "s390x")
return "s390x-unknown-linux-gnu";
if (arch === "riscv64")
return "riscv64gc-unknown-linux-gnu";
// MUSL builds
if (arch === "arm64-musl")
return "aarch64-unknown-linux-musl";
if (arch === "x64-musl" || arch === "x86_64-musl")
return "x86_64-unknown-linux-musl";
if (arch === "x86-musl" || arch === "ia32-musl")
return "i686-unknown-linux-musl";
if (arch === "arm-musl")
return "arm-unknown-linux-musleabihf";
if (arch === "armv7-musl")
return "armv7-unknown-linux-musleabihf";
// Default to standard x64 GNU build for unknown architectures
return "x86_64-unknown-linux-gnu";
}
else if (platform === "win32") {
if (arch === "arm64")
return "aarch64-pc-windows-msvc";
if (arch === "x86" || arch === "ia32")
return "i686-pc-windows-msvc";
return "x86_64-pc-windows-msvc";
}
throw new Error(`Unsupported platform for uv: ${platform}-${arch}`);
}
export class RuntimeInjector {
constructor(options) {
const runtimeType = options.type || "node";
this.config = RUNTIME_CONFIGS[runtimeType];
this.options = {
type: runtimeType,
version: options.version || this.config.defaultVersion,
platform: options.platform || process.platform,
arch: options.arch || process.arch,
targetDir: options.targetDir,
cleanup: options.cleanup ?? true,
};
this.runtimeInfo = {
type: this.options.type,
version: this.options.version,
platform: this.options.platform,
arch: this.options.arch,
targetDir: this.options.targetDir,
executablePath: this.config.getExecutablePath(this.options.targetDir, this.options.platform),
};
}
async isAlreadyInstalled() {
try {
const markerFile = path.join(this.runtimeInfo.targetDir, `${this.runtimeInfo.type}_${this.runtimeInfo.platform}_${this.runtimeInfo.arch}`);
if (await fs.pathExists(markerFile)) {
const installedVersion = await fs.readFile(markerFile, "utf8");
if (installedVersion.trim() === this.runtimeInfo.version) {
if (await fs.pathExists(this.runtimeInfo.executablePath)) {
try {
const execPath = this.runtimeInfo.executablePath.replace(/(\s+)/g, "\\$1");
let testCommand;
switch (this.runtimeInfo.type) {
case "node":
testCommand = `"${execPath}" -v`;
break;
case "bun":
testCommand = `"${execPath}" --version`;
break;
case "uv":
testCommand = `"${execPath}" --version`;
break;
default:
throw new Error(`Unknown runtime type: ${this.runtimeInfo.type}`);
}
const { stdout } = await execAsync(testCommand);
const actualVersion = stdout.trim();
// For different runtimes, version formats may vary
if (this.runtimeInfo.type === "node") {
return actualVersion === this.runtimeInfo.version;
}
else if (this.runtimeInfo.type === "bun") {
return actualVersion.includes(this.runtimeInfo.version.replace("v", ""));
}
else if (this.runtimeInfo.type === "uv") {
return actualVersion.includes(this.runtimeInfo.version);
}
return true;
}
catch (err) {
if (err instanceof Error) {
console.log(`${this.runtimeInfo.type} execution test failed, will redownload: ${err.message}`);
}
else {
console.log(`${this.runtimeInfo.type} execution test failed, will redownload`);
}
}
}
}
}
return false;
}
catch (error) {
console.error(`Failed to check ${this.runtimeInfo.type} installation:`, error);
return false;
}
}
async downloadFile(url, destination) {
console.log(`Downloading: ${url}`);
await fs.ensureDir(path.dirname(destination));
const writer = createWriteStream(destination);
const response = await axios({
method: "GET",
url: url,
responseType: "stream",
});
await pipeline(response.data, writer);
console.log(`File downloaded to: ${destination}`);
}
async extractTarGz(filePath, destination) {
console.log(`Extracting: ${filePath}`);
await fs.ensureDir(destination);
await tar.extract({
file: filePath,
cwd: destination,
});
console.log(`Files extracted to: ${destination}`);
}
async extractZip(filePath, destination) {
console.log(`Extracting: ${filePath}`);
await fs.ensureDir(destination);
try {
if (process.platform === "win32") {
await execAsync(`powershell -command "Expand-Archive -Path '${filePath}' -DestinationPath '${destination}' -Force"`);
}
else {
await execAsync(`unzip -o "${filePath}" -d "${destination}"`);
}
console.log(`ZIP file extracted to: ${destination}`);
}
catch (error) {
console.error("Extraction failed:", error);
throw error;
}
}
async cleanupRuntime(cleanupConfig) {
const targetDir = this.runtimeInfo.targetDir;
if (this.runtimeInfo.platform === "darwin" ||
this.runtimeInfo.platform === "linux") {
console.log("Cleaning up for macOS/Linux: Removing share and include directories.");
const shareDir = path.join(targetDir, "share");
const includeDir = path.join(targetDir, "include");
try {
if (await fs.pathExists(shareDir)) {
await fs.remove(shareDir);
console.log(`Removed directory: ${shareDir}`);
}
if (await fs.pathExists(includeDir)) {
await fs.remove(includeDir);
console.log(`Removed directory: ${includeDir}`);
}
}
catch (error) {
console.error(`Error during directory removal: ${error}`);
}
}
else {
const { removeDocs = true, removeDevFiles = true, removeSourceMaps = true, customRules = [], } = cleanupConfig;
const patterns = [
...(removeDocs
? [
"**/*.md",
"**/docs/**",
"**/doc/**",
"share/doc/**",
"share/man/**",
]
: []),
...(removeDevFiles
? ["**/*.h", "**/*.cc", "**/*.cpp", "**/*.c", "include/**"]
: []),
...(removeSourceMaps ? ["**/*.map"] : []),
...customRules.map((rule) => rule.pattern),
];
for (const pattern of patterns) {
console.log(`Cleaning up based on pattern: ${pattern}`);
try {
const files = await glob(pattern, {
cwd: targetDir,
absolute: true,
nodir: true,
});
console.log(`Found ${files.length} files/items for pattern ${pattern}`);
for (const file of files) {
if (await fs.pathExists(file)) {
await fs.remove(file);
}
}
}
catch (error) {
console.error(`Error cleaning up pattern ${pattern}: ${error}`);
}
}
}
}
async inject() {
try {
console.log(`Checking ${this.runtimeInfo.type} ${this.runtimeInfo.version} for ${this.runtimeInfo.platform}-${this.runtimeInfo.arch}`);
await fs.ensureDir(this.runtimeInfo.targetDir);
if (await this.isAlreadyInstalled()) {
console.log(`${this.runtimeInfo.type} already installed, skipping download`);
return;
}
const downloadUrl = this.config.getDownloadUrl(this.runtimeInfo.version, this.runtimeInfo.platform, this.runtimeInfo.arch);
const fileExtension = this.config.getFileExtension(this.runtimeInfo.platform);
const fileName = `${this.runtimeInfo.type}-${this.runtimeInfo.version}.${fileExtension}`;
const tempDir = path.join(os.tmpdir(), "tiny-runtime-injector-temp");
const downloadPath = path.join(tempDir, fileName);
await fs.ensureDir(tempDir);
await this.downloadFile(downloadUrl, downloadPath);
await fs.emptyDir(this.runtimeInfo.targetDir);
const extractedDir = path.join(tempDir, "extracted");
await fs.ensureDir(extractedDir);
if (fileExtension === "zip") {
await this.extractZip(downloadPath, extractedDir);
}
else {
await this.extractTarGz(downloadPath, extractedDir);
}
// Use runtime-specific extraction logic
if (this.config.extractFiles) {
await this.config.extractFiles(extractedDir, this.runtimeInfo.targetDir, this.runtimeInfo.version, this.runtimeInfo.platform, this.runtimeInfo.arch);
}
// Set executable permissions for non-Windows platforms
if (this.runtimeInfo.platform !== "win32") {
const execPath = this.runtimeInfo.executablePath;
if (await fs.pathExists(execPath)) {
await fs.chmod(execPath, 0o755);
}
// For uv, also set permissions for uvx
if (this.runtimeInfo.type === "uv") {
const uvxPath = path.join(this.runtimeInfo.targetDir, "uvx");
if (await fs.pathExists(uvxPath)) {
await fs.chmod(uvxPath, 0o755);
}
}
// For Node.js, set permissions for all binaries
if (this.runtimeInfo.type === "node") {
const binDir = path.join(this.runtimeInfo.targetDir, "bin");
if (await fs.pathExists(binDir)) {
const binFiles = await fs.readdir(binDir);
for (const file of binFiles) {
const filePath = path.join(binDir, file);
const stats = await fs.stat(filePath);
if (stats.isFile()) {
await fs.chmod(filePath, 0o755);
}
}
}
}
}
const markerFile = path.join(this.runtimeInfo.targetDir, `${this.runtimeInfo.type}_${this.runtimeInfo.platform}_${this.runtimeInfo.arch}`);
await fs.writeFile(markerFile, this.runtimeInfo.version);
console.log(this.options.cleanup);
if (this.options.cleanup && this.runtimeInfo.type === "node") {
const cleanupConfig = typeof this.options.cleanup === "boolean"
? { removeDocs: true, removeDevFiles: true, removeSourceMaps: true }
: this.options.cleanup;
await this.cleanupRuntime(cleanupConfig);
}
await fs.remove(tempDir);
console.log(`${this.runtimeInfo.type} ${this.runtimeInfo.version} successfully installed to ${this.runtimeInfo.targetDir}`);
}
catch (error) {
console.error("Installation failed:", error);
throw error;
}
}
}