Files
Firelink/scripts/provision-engines.js
T

226 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const sourceLock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
);
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const target = argValue('--target')
|| process.env.FIRELINK_TARGET_TRIPLE
|| process.env.TAURI_ENV_TARGET_TRIPLE;
if (!target) {
console.error('Pass --target <Rust target triple>.');
process.exit(1);
}
const targetSources = sourceLock.targets?.[target];
if (!targetSources) {
console.error(`No source lock exists for ${target}.`);
process.exit(1);
}
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
const isWindows = target.includes('windows');
const executableSuffix = isWindows ? '.exe' : '';
const DOWNLOAD_ATTEMPTS = 3;
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000;
const DOWNLOAD_RETRY_DELAYS_MS = [2_000, 5_000];
const FILE_LOCK_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async function removeFileWithRetry(file) {
for (let attempt = 0; ; attempt += 1) {
try {
fs.rmSync(file, { force: true });
return;
} catch (error) {
const retryable = process.platform === 'win32'
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
if (!retryable || attempt >= FILE_LOCK_RETRY_DELAYS_MS.length) {
throw error;
}
await sleep(FILE_LOCK_RETRY_DELAYS_MS[attempt]);
}
}
}
function createDownloadTimeout() {
const controller = new AbortController();
let timer;
const refresh = () => {
clearTimeout(timer);
timer = setTimeout(() => {
controller.abort(new Error(`Download idle for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms`));
}, DOWNLOAD_IDLE_TIMEOUT_MS);
};
const dispose = () => clearTimeout(timer);
refresh();
return { signal: controller.signal, refresh, dispose };
}
async function download(name, source) {
const sourcePath = new URL(source.url).pathname;
const archive = path.join(
temporary,
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
);
let lastError;
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
const downloadTimeout = createDownloadTimeout();
try {
const response = await fetch(source.url, {
redirect: 'follow',
signal: downloadTimeout.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
await pipeline(
Readable.fromWeb(response.body),
new Transform({
transform(chunk, encoding, callback) {
downloadTimeout.refresh();
callback(null, chunk, encoding);
},
}),
fs.createWriteStream(archive),
{ signal: downloadTimeout.signal }
);
break;
} catch (error) {
lastError = error;
await removeFileWithRetry(archive);
if (attempt === DOWNLOAD_ATTEMPTS) {
throw new Error(
`Failed to download ${name} after ${DOWNLOAD_ATTEMPTS} attempts: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
);
}
await sleep(DOWNLOAD_RETRY_DELAYS_MS[attempt - 1]);
} finally {
downloadTimeout.dispose();
}
}
if (lastError && !fs.existsSync(archive)) {
throw lastError;
}
const actual = sha256(archive);
if (actual !== source.sha256) {
throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`);
}
const extracted = path.join(temporary, `${name}-extracted`);
fs.mkdirSync(extracted);
if (archive.endsWith('.zip') && process.platform !== 'win32') {
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
} else {
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
}
return extracted;
}
function findFile(root, names) {
const wanted = new Set(names.map(name => name.toLowerCase()));
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else if (entry.isFile() && wanted.has(entry.name.toLowerCase())) matches.push(file);
}
};
walk(root);
if (matches.length !== 1) {
throw new Error(`Expected one of [${names.join(', ')}] under ${root}, found ${matches.length}`);
}
return matches[0];
}
function copyExecutable(source, engine) {
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
fs.copyFileSync(source, output);
if (!isWindows) fs.chmodSync(output, 0o755);
}
function writePayloadManifest() {
const files = collectRegularFiles(destination, {
ignoredNames: ['payload-manifest.json'],
});
const manifest = {
schemaVersion: 1,
target,
generatedFrom: Object.fromEntries(
Object.entries(targetSources).map(([name, source]) => [
name,
{
version: source.version,
url: source.url || source.sourceUrl,
sha256: source.sha256 || source.sourceSha256
}
])
),
files: Object.fromEntries(
files.map(file => [
path.relative(destination, file).split(path.sep).join('/'),
sha256(file)
])
)
};
fs.writeFileSync(
path.join(destination, 'payload-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
}
try {
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
copyExecutable(
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
'yt-dlp'
);
fs.cpSync(path.join(ytdlp, '_internal'), path.join(destination, '_internal'), {
recursive: true,
preserveTimestamps: true
});
const deno = await download('deno', targetSources.deno);
copyExecutable(findFile(deno, isWindows ? ['deno.exe'] : ['deno']), 'deno');
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
const aria2 = await download('aria2c', targetSources.aria2c);
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
writePayloadManifest();
console.log(`Provisioned locked engine payload at ${destination}`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}