// ---- Types ------------------------------------------------------------------ export type ServerType = "vanilla" | "paper" | "spigot" | "fabric" | "forge" | "bedrock" export interface VersionInfo { id: string type?: string releaseTime?: string url?: string } // ---- In-memory cache -------------------------------------------------------- interface CacheEntry { data: T expiresAt: number } const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes const cache = new Map>() function cacheGet(key: string): T | null { const entry = cache.get(key) if (!entry) return null if (Date.now() > entry.expiresAt) { cache.delete(key) return null } return entry.data as T } function cacheSet(key: string, data: T): void { cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }) } // ---- Vanilla ---------------------------------------------------------------- interface MojangManifest { latest: { release: string; snapshot: string } versions: Array<{ id: string type: string url: string releaseTime: string sha1: string }> } /** Fetch all versions from the official Mojang version manifest. */ export async function fetchVanillaVersions(): Promise { const key = "vanilla:versions" const cached = cacheGet(key) if (cached) return cached const res = await fetch( "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json", ) if (!res.ok) { throw new Error(`Failed to fetch Mojang manifest: ${res.status} ${res.statusText}`) } const manifest: MojangManifest = await res.json() const versions: VersionInfo[] = manifest.versions.map((v) => ({ id: v.id, type: v.type, releaseTime: v.releaseTime, url: v.url, })) cacheSet(key, versions) return versions } // ---- Paper ------------------------------------------------------------------ interface PaperBuildsResponse { project_id: string project_name: string version: string builds: number[] } /** Fetch all Paper MC versions from the PaperMC API. */ export async function fetchPaperVersions(): Promise { const key = "paper:versions" const cached = cacheGet(key) if (cached) return cached const res = await fetch("https://api.papermc.io/v2/projects/paper") if (!res.ok) { throw new Error(`Failed to fetch Paper versions: ${res.status} ${res.statusText}`) } const data: { versions: string[] } = await res.json() const versions: VersionInfo[] = data.versions.map((id) => ({ id })) cacheSet(key, versions) return versions } // ---- Fabric ----------------------------------------------------------------- interface FabricGameVersion { version: string stable: boolean } /** Fetch all Fabric-supported Minecraft versions. */ export async function fetchFabricVersions(): Promise { const key = "fabric:versions" const cached = cacheGet(key) if (cached) return cached const res = await fetch("https://meta.fabricmc.net/v2/versions/game") if (!res.ok) { throw new Error(`Failed to fetch Fabric versions: ${res.status} ${res.statusText}`) } const data: FabricGameVersion[] = await res.json() const versions: VersionInfo[] = data.map((v) => ({ id: v.version, type: v.stable ? "release" : "snapshot", })) cacheSet(key, versions) return versions } // ---- Download URL resolution ------------------------------------------------ /** * Resolve the direct download URL for a given server type + version. * Throws if the type/version combination cannot be resolved. */ export async function getDownloadUrl( type: ServerType, version: string, ): Promise { validateVersion(version) switch (type) { case "vanilla": return getVanillaDownloadUrl(version) case "paper": return getPaperDownloadUrl(version) case "fabric": return getFabricDownloadUrl(version) case "spigot": throw new Error( "Spigot cannot be downloaded directly; use BuildTools instead.", ) case "forge": throw new Error( "Forge installers must be downloaded from files.minecraftforge.net.", ) case "bedrock": throw new Error( "Bedrock server downloads require manual acceptance of Microsoft's EULA.", ) default: throw new Error(`Unsupported server type: ${type}`) } } async function getVanillaDownloadUrl(version: string): Promise { const versions = await fetchVanillaVersions() const entry = versions.find((v) => v.id === version) if (!entry?.url) throw new Error(`Vanilla version not found: ${version}`) // The URL points to a version JSON; fetch it to get the server jar URL const cacheKey = `vanilla:jar-url:${version}` const cached = cacheGet(cacheKey) if (cached) return cached const res = await fetch(entry.url) if (!res.ok) { throw new Error(`Failed to fetch version manifest for ${version}: ${res.status}`) } const versionData: { downloads: { server?: { url: string } } } = await res.json() const url = versionData.downloads.server?.url if (!url) throw new Error(`No server download available for vanilla ${version}`) cacheSet(cacheKey, url) return url } async function getPaperDownloadUrl(version: string): Promise { const cacheKey = `paper:jar-url:${version}` const cached = cacheGet(cacheKey) if (cached) return cached // Get the latest build number for this version const buildsRes = await fetch( `https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}`, ) if (!buildsRes.ok) { throw new Error(`Paper version ${version} not found: ${buildsRes.status}`) } const buildsData: PaperBuildsResponse = await buildsRes.json() const latestBuild = buildsData.builds.at(-1) if (latestBuild === undefined) { throw new Error(`No builds found for Paper ${version}`) } const url = `https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}/builds/${latestBuild}/downloads/paper-${version}-${latestBuild}.jar` cacheSet(cacheKey, url) return url } async function getFabricDownloadUrl(version: string): Promise { const cacheKey = `fabric:jar-url:${version}` const cached = cacheGet(cacheKey) if (cached) return cached // Get latest loader and installer versions const [loadersRes, installersRes] = await Promise.all([ fetch("https://meta.fabricmc.net/v2/versions/loader"), fetch("https://meta.fabricmc.net/v2/versions/installer"), ]) if (!loadersRes.ok || !installersRes.ok) { throw new Error("Failed to fetch Fabric loader/installer versions") } const loaders: Array<{ version: string; stable: boolean }> = await loadersRes.json() const installers: Array<{ version: string; stable: boolean }> = await installersRes.json() const latestLoader = loaders.find((l) => l.stable) const latestInstaller = installers.find((i) => i.stable) if (!latestLoader || !latestInstaller) { throw new Error("Could not determine latest stable Fabric loader/installer") } const url = `https://meta.fabricmc.net/v2/versions/loader/${encodeURIComponent(version)}/${encodeURIComponent(latestLoader.version)}/${encodeURIComponent(latestInstaller.version)}/server/jar` cacheSet(cacheKey, url) return url } // ---- Download --------------------------------------------------------------- /** * Download a server jar to `destPath`. * @param onProgress - optional callback receiving bytes downloaded and total bytes */ export async function downloadServer( type: ServerType, version: string, destPath: string, onProgress?: (downloaded: number, total: number) => void, ): Promise { validateDestPath(destPath) const url = await getDownloadUrl(type, version) const res = await fetch(url) if (!res.ok) { throw new Error(`Failed to download server jar: ${res.status} ${res.statusText}`) } const contentLength = Number(res.headers.get("content-length") ?? "0") const body = res.body if (!body) throw new Error("Response body is empty") const file = Bun.file(destPath) const writer = file.writer() let downloaded = 0 const reader = body.getReader() try { while (true) { const { done, value } = await reader.read() if (done) break writer.write(value) downloaded += value.byteLength if (onProgress && contentLength > 0) { onProgress(downloaded, contentLength) } } await writer.end() } catch (err) { writer.end() throw err } finally { reader.releaseLock() } console.log( `[Versions] Downloaded ${type} ${version} → ${destPath} (${downloaded} bytes)`, ) } // ---- Input validation ------------------------------------------------------- /** Minecraft version strings are like "1.21.4", "24w44a" - allow alphanumeric + dots + dashes */ function validateVersion(version: string): void { if (!version || typeof version !== "string") { throw new Error("Version must be a non-empty string") } if (!/^[\w.\-+]{1,64}$/.test(version)) { throw new Error(`Invalid version string: ${version}`) } } /** Prevent path traversal in destination paths */ function validateDestPath(destPath: string): void { if (!destPath || typeof destPath !== "string") { throw new Error("Destination path must be a non-empty string") } if (destPath.includes("..")) { throw new Error("Destination path must not contain '..'") } if (!destPath.endsWith(".jar")) { throw new Error("Destination path must end with .jar") } }