325 lines
9.4 KiB
TypeScript
325 lines
9.4 KiB
TypeScript
// ---- 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<T> {
|
|
data: T
|
|
expiresAt: number
|
|
}
|
|
|
|
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
const cache = new Map<string, CacheEntry<unknown>>()
|
|
|
|
function cacheGet<T>(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<T>(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<VersionInfo[]> {
|
|
const key = "vanilla:versions"
|
|
const cached = cacheGet<VersionInfo[]>(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<VersionInfo[]> {
|
|
const key = "paper:versions"
|
|
const cached = cacheGet<VersionInfo[]>(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<VersionInfo[]> {
|
|
const key = "fabric:versions"
|
|
const cached = cacheGet<VersionInfo[]>(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<string> {
|
|
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<string> {
|
|
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<string>(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<string> {
|
|
const cacheKey = `paper:jar-url:${version}`
|
|
const cached = cacheGet<string>(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<string> {
|
|
const cacheKey = `fabric:jar-url:${version}`
|
|
const cached = cacheGet<string>(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<void> {
|
|
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")
|
|
}
|
|
}
|