UNPKG

@origami-minecraft/stable

Version:

Origami is a terminal-first Minecraft launcher that supports authentication, installation, and launching of Minecraft versions — with built-in support for Microsoft accounts, mod loaders, profile management, and more. Designed for power users, modders, an

311 lines (254 loc) 10 kB
import axios from "axios"; import fs from "fs-extra"; import { ORIGAMi_USER_AGENT } from "../../config/defaults"; import { logger } from "../game/launch/handler"; import path from "path"; import http from "http"; import https from "https"; import EventEmitter from "events"; import { v4 as uuid } from "uuid"; import { ensureDir, localpath } from "./common"; import { rename } from "fs/promises"; import LauncherOptionsManager from "../game/launch/options"; import pLimit from "p-limit"; const CHUNK_SIZE = 1024 * 1024 * 5; const MIN_SIZE_FOR_PARALLEL = 1024 * 1024 * 10; const TEMP_PREFIX = '.origami.temp-'; export function _temp_safe(): string { const temp_folder = path.join(localpath(true), 'download_cache'); ensureDir(temp_folder); const MAX_AGE_MS = 1000 * 60 * 60; fs.readdirSync(temp_folder).forEach(file => { if (file.startsWith(TEMP_PREFIX)) { const filePath = path.join(temp_folder, file); try { const stats = fs.statSync(filePath); const age = Date.now() - stats.mtimeMs; if (age > MAX_AGE_MS) { fs.unlinkSync(filePath); } } catch (err) { logger.warn?.(`[TEMP CLEANUP]: Could not clean ${filePath}: ${(err as Error).message}`); } } }); const temp_file = `${TEMP_PREFIX}${uuid()}`; return path.join(temp_folder, temp_file); } export async function parallelDownloader( url: string, outputPath: string, totalSize: number, agent?: http.Agent | https.Agent, emitter?: EventEmitter, type = "Download" ): Promise<void> { const tempDir = path.join(localpath(true), "download_cache"); ensureDir(tempDir); const chunkCount = Math.ceil(totalSize / CHUNK_SIZE); const limit = pLimit(new LauncherOptionsManager().getFixedOptions().connections); const tempFiles: { index: number; path: string }[] = []; let downloadedBytes = 0; const downloadChunk = async (index: number, start: number, end: number) => { const tempFilePath = path.join(tempDir, `${TEMP_PREFIX}filechunk${index}-${uuid()}`); tempFiles.push({ index, path: tempFilePath }); const response = await axios({ url, method: "GET", responseType: "stream", headers: { Range: `bytes=${start}-${end}`, "User-Agent": ORIGAMi_USER_AGENT, }, httpAgent: agent, httpsAgent: agent, timeout: 30000, validateStatus: status => status >= 200 && status < 400, }); if (response.status !== 206 && chunkCount > 1) { throw new Error(`Expected 206 Partial Content, got ${response.status}`); } let downloadedChunkBytes = 0; const writer = fs.createWriteStream(tempFilePath); response.data.on("data", (chunk: Buffer) => { downloadedChunkBytes += chunk.length; downloadedBytes += chunk.length; emitter?.emit("download-status", { name: path.basename(outputPath), type, current: downloadedBytes, total: totalSize, }); }); response.data.pipe(writer); await new Promise<void>((resolve, reject) => { writer.on("finish", resolve); writer.on("error", reject); }); }; const tasks = Array.from({ length: chunkCount }, (_, index) => { const start = index * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE - 1, totalSize - 1); return () => limit(() => downloadChunk(index, start, end)); }); await Promise.all(tasks.map(task => task())); tempFiles.sort((a, b) => a.index - b.index); const finalWriter = fs.createWriteStream(outputPath); for (const { path: tempFile } of tempFiles) { await fs.appendFile(outputPath, await fs.readFile(tempFile)); await fs.remove(tempFile); } finalWriter.end(); await new Promise<void>((resolve, reject) => { finalWriter.on("finish", resolve); finalWriter.on("error", reject); }); } export async function downloader(url: string, outputPath: string): Promise<void> { const progress_manager = logger.progress(); const download_progress = progress_manager.create(path.basename(outputPath), 1); try { const response = await axios({ url, method: 'GET', responseType: 'stream', maxRedirects: 5, headers: { 'User-Agent': ORIGAMi_USER_AGENT, } }); const headResp = await axios.head(url, { headers: { 'User-Agent': ORIGAMi_USER_AGENT }, maxRedirects: 5 }); const total = parseInt(headResp.headers['content-length'] || '1', 10); const acceptsRanges = headResp.headers['accept-ranges'] === 'bytes'; download_progress?.total(total); progress_manager.start(); let downloaded = 0; if (acceptsRanges && total >= MIN_SIZE_FOR_PARALLEL) { download_progress?.total(total); progress_manager.start(); await parallelDownloader( url, outputPath, total, undefined, { emit: (event, payload) => { if (event === "download-status" && payload?.current && payload?.total) { download_progress?.total(payload.total); download_progress?.update(payload.current); } } } as EventEmitter, "Parallel" ); download_progress?.stop(); return; } response.data.on('data', (chunk: Buffer) => { downloaded += chunk.length; if (total) { download_progress?.update(downloaded); } else { download_progress?.total(downloaded); download_progress?.update(downloaded); } }); const temp_safe = _temp_safe(); const writer = fs.createWriteStream(temp_safe); response.data.pipe(writer); await new Promise<void>((resolve, reject) => { async function _resolve(...args: any[]) { await new Promise(res => setTimeout(res, 100)); let dir = path.dirname(outputPath); ensureDir(dir); await rename(temp_safe, outputPath); await new Promise(res => setTimeout(res, 100)); resolve(...args); } async function _reject(...args: any[]) { await new Promise(res => setTimeout(res, 100)); reject(...args); } writer.on('finish', _resolve); writer.on('error', _reject); }); download_progress?.stop(); } catch (error: any) { logger.error((error as Error).message); download_progress?.stop(true); } } export async function downloadAsync( url: string, targetPath: string, retry = true, type = "Download", maxRetries = 2, agent?: http.Agent | https.Agent, emitter?: EventEmitter ): Promise<boolean | { failed: boolean; asset: string | null }> { let attempt = 0; while (attempt <= maxRetries) { try { const response = await axios({ url, method: "GET", responseType: "stream", headers: { "User-Agent": ORIGAMi_USER_AGENT, }, httpAgent: agent, httpsAgent: agent, timeout: 50000, maxContentLength: Infinity, maxBodyLength: Infinity, validateStatus: (status) => status < 400 }); const totalBytes = parseInt(response.headers["content-length"] || "0", 10); const temp_safe = _temp_safe(); let receivedBytes = 0; await new Promise<void>((resolve, reject) => { const fileStream = fs.createWriteStream(temp_safe); response.data.on("data", (chunk: Buffer) => { receivedBytes += chunk.length; emitter?.emit("download-status", { name: path.basename(targetPath), type, current: receivedBytes, total: totalBytes, }); }); response.data.pipe(fileStream); fileStream.on("finish", async() => { await new Promise(res => setTimeout(res, 100)); let dir = path.dirname(targetPath); ensureDir(dir); await rename(temp_safe, targetPath); emitter?.emit("download", targetPath); resolve(); }); fileStream.on("error", (err) => { reject(err); }); response.data.on("error", (err: any) => { reject(err); }); }); return true; } catch (err: any) { emitter?.emit("debug", `[DOWNLOADER]: Failed to download ${url} to ${targetPath}:\n${err.message}`); if (fs.existsSync(targetPath)) fs.unlinkSync(targetPath); attempt++; if (attempt > maxRetries || !retry) { return { failed: true, asset: null }; } const wait = 500 * Math.pow(2, attempt - 1); emitter?.emit("debug", `[DOWNLOADER]: Retrying download (${attempt}/${maxRetries}) after ${wait}ms...`); await new Promise(res => setTimeout(res, wait)); } } return { failed: true, asset: null }; }