Skip to content

Commit

Permalink
feat: add experimental sea support
Browse files Browse the repository at this point in the history
  • Loading branch information
robertsLando committed Oct 22, 2024
1 parent f9f474b commit d51b02f
Show file tree
Hide file tree
Showing 2 changed files with 268 additions and 0 deletions.
10 changes: 10 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Target, NodeTarget, SymLinks } from './types';
import { CompressType } from './compress_type';
import { patchMachOExecutable, signMachOExecutable } from './mach-o';
import pkgOptions from './options';
import sea from './sea';

const { version } = JSON.parse(
readFileSync(path.join(__dirname, '../package.json'), 'utf-8'),
Expand Down Expand Up @@ -226,6 +227,7 @@ export async function exec(argv2: string[]) {
'v',
'version',
'signature',
'sea'
],
string: [
'_',
Expand Down Expand Up @@ -530,6 +532,14 @@ export async function exec(argv2: string[]) {
}
}

if(argv.sea) {
for (const t of targets) {
// TODO: add support for sea config options
await sea(inputFin, t.output as string, { target: t });
}
return
}

// fetch targets

const { bytecode } = argv;
Expand Down
258 changes: 258 additions & 0 deletions lib/sea.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { exec as cExec } from 'child_process';
import util from 'util';
import { dirname, join, resolve } from 'path';
import { copyFile, writeFile, rm, mkdir, stat, readFile } from 'fs/promises';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
import { ReadableStream } from 'stream/web';
import { createHash } from 'crypto';
import { homedir, tmpdir } from 'os';
import { log } from './log';
import { NodeTarget } from './types';

const exec = util.promisify(cExec);

/** Returns stat of path when exits, false otherwise */
const exists = async (path: string) => {
try {
return await stat(path);
} catch {
return false;
}
};

export type GetNodejsExecutableOptions = {
useLocalNode?: boolean;
nodePath?: string;
target: NodeTarget;
};

export type SeaConfig = {
disableExperimentalSEAWarning: boolean;
useSnapshot: boolean;
useCodeCache: boolean;
};

export type SeaOptions = {
seaConfig?: SeaConfig;
} & GetNodejsExecutableOptions;

const defaultSeaConfig: SeaConfig = {
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: false,
};

async function downloadFile(url: string, filePath: string): Promise<void> {
const response = await fetch(url);
if (!response.ok || !response.body) {
throw new Error(`Failed to download file from ${url}`);
}

const fileStream = createWriteStream(filePath);
return pipeline(response.body as unknown as ReadableStream, fileStream);
}

async function verifyChecksum(
filePath: string,
checksumUrl: string,
fileName: string,
): Promise<void> {
const response = await fetch(checksumUrl);
if (!response.ok) {
throw new Error(`Failed to download checksum file from ${checksumUrl}`);
}

const checksums = await response.text();
const expectedChecksum = checksums
.split('\n')
.find((line) => line.includes(fileName))
?.split(' ')[0];

if (!expectedChecksum) {
throw new Error(`Checksum for ${fileName} not found`);
}

const fileBuffer = await readFile(filePath);
const hashSum = createHash('sha256');
hashSum.update(fileBuffer);

const actualChecksum = hashSum.digest('hex');
if (actualChecksum !== expectedChecksum) {
throw new Error(`Checksum verification failed for ${fileName}`);
}
}

const allowedArchs = ['x64', 'arm64', 'armv7l', 'ppc64', 's390x'];
const allowedOSs = ['darwin', 'linux', 'win32'];

function getNodeOs(platform: string) {
const platformsMap: Record<string, string> = {
macos: 'darwin',
win: 'win32',
};

const validatedPlatform = platformsMap[platform] || platform;

if (!allowedOSs.includes(validatedPlatform)) {
throw new Error(`Unsupported OS: ${platform}`);
}

return validatedPlatform;
}

function getNodeArch(arch: string) {
if (!allowedArchs.includes(arch)) {
throw new Error(`Unsupported architecture: ${arch}`);
}

return arch;
}

async function getNodeVersion(nodeVersion: string) {
// validate nodeVersion using regex. Allowed formats: 16, 16.0, 16.0.0
const regex = /^\d{1,2}(\.\d{1,2}){0,2}$/;
if (!regex.test(nodeVersion)) {
throw new Error('Invalid node version format');
}

const parts = nodeVersion.split('.');

if (parts.length > 3) {
throw new Error('Invalid node version format');
}

if (parts.length === 3) {
return nodeVersion;
}

const response = await fetch('https://nodejs.org/dist/index.json');

if (!response.ok) {
throw new Error('Failed to fetch node versions');
}

const versions = await response.json();

const latestVersion = versions
.map((v: { version: string }) => v.version)
.find((v: string) => v.startsWith(nodeVersion));

if (!latestVersion) {
throw new Error(`Node version ${nodeVersion} not found`);
}

return latestVersion;
}

async function getNodejsExecutable(opts: GetNodejsExecutableOptions) {
if (opts.nodePath) {
// check if the nodePath exists
if (!(await exists(opts.nodePath))) {
throw new Error(
`Priovided node executable path "${opts.nodePath}" does not exist`,
);
}

return opts.nodePath;
}

if (opts.useLocalNode) {
return process.execPath;
}

const nodeVersion = await getNodeVersion(
opts.target.nodeRange.replace('nodev', ''),
);

const os = getNodeOs(opts.target.platform);
const arch = getNodeArch(opts.target.arch);

const fileName = `node-v${nodeVersion}-${os}-${arch}.tar.gz`;
const url = `https://nodejs.org/dist/v${nodeVersion}/${fileName}`;
const checksumUrl = `https://nodejs.org/dist/v${nodeVersion}/SHASUMS256.txt`;
const downloadDir = join(homedir(), '.pkg-cache', 'sea');

// Ensure the download directory exists
if (!(await exists(downloadDir))) {
await mkdir(downloadDir, { recursive: true });
}

const filePath = join(downloadDir, fileName);

await downloadFile(url, filePath);
await verifyChecksum(filePath, checksumUrl, fileName);

return filePath;
}

export default async function sea(
entryPoint: string,
outPath: string,
opts: SeaOptions,
) {
entryPoint = resolve(process.cwd(), entryPoint);
outPath = resolve(process.cwd(), outPath);

if (!(await exists(entryPoint))) {
throw new Error(`Entrypoint path "${entryPoint}" does not exist`);
}
if (!(await exists(dirname(outPath)))) {
throw new Error(`Output directory "${dirname(outPath)}" does not exist`);
}
// check if executable_path exists
if (await exists(outPath)) {
log.warn(`Executable ${outPath} already exists, will be overwritten`);
}

const nodeMajor = parseInt(process.version.slice(1).split('.')[0], 10);
// check node version, needs to be at least 20.0.0
if (nodeMajor < 20) {
throw new Error(
`SEA support requires as least node v20.0.0, actual node version is ${process.version}`,
);
}

// get the node executable
const nodePath = await getNodejsExecutable(opts);

// copy the executable as the output executable
await copyFile(nodePath, outPath);

// create a temporary directory for the processing work
const tmpDir = join(tmpdir(), 'pkg-sea', `${Date.now()}`);

await mkdir(tmpDir, { recursive: true });

try {
process.chdir(tmpDir);

// docs: https://nodejs.org/api/single-executable-applications.html
const blobPath = join(tmpDir, 'sea-prep.blob');
const seaConfigFilePath = join(tmpDir, 'sea-config.json');
const seaConfig = {
main: entryPoint,
output: blobPath,
...{
...defaultSeaConfig,
...(opts.seaConfig || {}),
}
};

log.info('Preparing the executable');
await writeFile(seaConfigFilePath, JSON.stringify(seaConfig));

log.info('Generating the blob...');
await exec(`node --experimental-sea-config "${seaConfigFilePath}"`);

log.info('Injecting the blob...');
await exec(
`npx postject "${outPath}" NODE_SEA_BLOB "${blobPath}" --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2`,
);
} catch (error) {
throw new Error(`Error while creating the executable: ${error}`);
} finally {
// cleanup the temp directory
await rm(tmpDir, { recursive: true });
}
}

0 comments on commit d51b02f

Please sign in to comment.