From d51b02f86b0b05e628d247af6b001fad8cc14751 Mon Sep 17 00:00:00 2001 From: Daniel Lando Date: Tue, 22 Oct 2024 11:41:05 +0200 Subject: [PATCH] feat: add experimental `sea` support --- lib/index.ts | 10 ++ lib/sea.ts | 258 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 lib/sea.ts diff --git a/lib/index.ts b/lib/index.ts index d7c27fe2..227e93a0 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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'), @@ -226,6 +227,7 @@ export async function exec(argv2: string[]) { 'v', 'version', 'signature', + 'sea' ], string: [ '_', @@ -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; diff --git a/lib/sea.ts b/lib/sea.ts new file mode 100644 index 00000000..43c6d651 --- /dev/null +++ b/lib/sea.ts @@ -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 { + 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 { + 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 = { + 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 }); + } +}