forked from vercel/pkg
-
-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f9f474b
commit d51b02f
Showing
2 changed files
with
268 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} | ||
} |