UNPKG

fivem-server-api

Version:

Query FiveM server info, player list, player count, resources, tags, locale, OneSync, game build, and more from any server IP or CFX.re URL. Also includes global server search with filtering, pagination, icon URL helper, private server detection, caching,

405 lines (404 loc) 14.6 kB
import axios from "axios"; import { FiveMError } from "./errors.js"; const DEFAULT_OPTIONS = { timeout: 5000, retries: 0, retryDelay: 1000, cacheTtl: 5000, debug: false, minInterval: 0, }; export { FiveMError } from "./errors.js"; export { searchServers, getAllServers, getServerByEndpoint, getServersByLocale, getIconUrl, isPrivateServer, } from "./search.js"; export default class Server { #ip = ""; #ready; #options; #logger = null; #infoCache = null; #dynamicCache = null; #lastPlayerCount = 0; #lastRequestTime = 0; constructor(cfxre, options = {}) { if (!cfxre || typeof cfxre !== "string" || !cfxre.trim()) { throw new FiveMError("Please provide a valid CFX.re URL or IP:Port.", { method: "constructor" }); } this.#options = { ...DEFAULT_OPTIONS, ...options }; if (this.#options.debug) { this.#logger = typeof this.#options.debug === "function" ? this.#options.debug : (msg) => console.log(`[fivem-server-api] ${msg}`); } const input = cfxre.trim(); if (this.#isCfxreUrl(input)) { this.#ready = this.#resolveCfxre(input); } else { this.#validateIpPort(input); this.#ip = input; this.#ready = Promise.resolve(); } } ready() { return this.#ready; } clearCache() { this.#infoCache = null; this.#dynamicCache = null; this.#log("Cache cleared"); } get playerCount() { return this.#lastPlayerCount; } #isCfxreUrl(input) { if (input.startsWith("cfx.re") || input.startsWith("https://cfx.re")) { return true; } if (!input.includes(".") && !input.includes(":")) { return true; } return false; } #validateIpPort(input) { const colonIndex = input.lastIndexOf(":"); const portStr = colonIndex > 0 ? input.slice(colonIndex + 1) : ""; if (colonIndex > 0) { const port = Number(portStr); if (portStr === "" || !Number.isInteger(port) || port < 1 || port > 65535) { throw new FiveMError(`Invalid port "${portStr}" in "${input}". Port must be 165535.`, { method: "constructor" }); } } } #log(msg) { this.#logger?.(msg); } async #resolveCfxre(input) { let url; if (input.startsWith("cfx.re/join/")) { url = "https://" + input; } else if (input.startsWith("https://cfx.re/join/")) { url = input; } else { url = "https://cfx.re/join/" + input; } this.#log(`Resolving CFX.re: ${url}`); let response; try { response = await axios.get(url, { timeout: this.#options.timeout, responseType: "text", }); } catch (err) { throw new FiveMError(`Failed to resolve CFX.re URL "${input}". The server may be offline or the code is invalid.`, { method: "cfxre", url, cause: err }); } const citizenUrl = response?.headers?.["x-citizenfx-url"]; if (!citizenUrl) { throw new FiveMError(`No x-citizenfx-url header in response for "${input}". Please verify the URL.`, { method: "cfxre", url }); } try { const parsed = new URL(citizenUrl); this.#ip = parsed.host; this.#log(`Resolved to: ${this.#ip}`); } catch { throw new FiveMError(`Failed to parse server address "${citizenUrl}" from CFX.re header.`, { method: "cfxre", url }); } } async #throttle() { if (this.#options.minInterval <= 0) return; const elapsed = Date.now() - this.#lastRequestTime; if (elapsed < this.#options.minInterval) { const delay = this.#options.minInterval - elapsed; this.#log(`Throttling: waiting ${delay}ms`); await new Promise((r) => setTimeout(r, delay)); } } async #fetch(url, config = {}) { await this.#throttle(); const maxAttempts = this.#options.retries + 1; let lastError; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { this.#log(`GET ${url}${attempt > 0 ? ` (retry ${attempt})` : ""}`); const response = await axios.get(url, { timeout: this.#options.timeout, responseType: "json", ...config, }); this.#lastRequestTime = Date.now(); this.#log(`OK ${url} (${JSON.stringify(response.data).length} bytes)`); return response; } catch (err) { lastError = err; this.#log(`FAIL ${url}: ${err.message}`); if (attempt < maxAttempts - 1) { await new Promise((r) => setTimeout(r, this.#options.retryDelay)); } } } throw lastError; } async #getInfo() { await this.#ready; if (this.#infoCache && Date.now() - this.#infoCache.timestamp < this.#options.cacheTtl) { return this.#infoCache.data; } try { const response = await this.#fetch(`http://${this.#ip}/info.json`); this.#infoCache = { data: response.data, timestamp: Date.now() }; if (Array.isArray(response.data?.players)) { this.#lastPlayerCount = response.data.players.length; } return this.#infoCache.data; } catch (err) { throw new FiveMError("Failed to fetch server info.", { method: "getInfo", url: `http://${this.#ip}/info.json`, cause: err }); } } async #getDynamic() { await this.#ready; if (this.#dynamicCache && Date.now() - this.#dynamicCache.timestamp < this.#options.cacheTtl) { return this.#dynamicCache.data; } try { const response = await this.#fetch(`http://${this.#ip}/dynamic.json`); this.#dynamicCache = { data: response.data, timestamp: Date.now() }; return this.#dynamicCache.data; } catch { this.#dynamicCache = null; return null; } } #getField(obj, path) { const keys = path.split("."); let current = obj; for (const key of keys) { if (current && typeof current === "object") { current = current[key]; } else { return undefined; } } return current; } async getServerStatus() { try { await this.#ready; await this.#fetch(`http://${this.#ip}/info.json`); return true; } catch { return false; } } async getServer() { return this.#getInfo(); } async getServerName() { const info = await this.#getInfo(); const name = this.#getField(info, "vars.sv_projectName"); return name?.replace(/^\^\d+/, "") || ""; } async getServerDesc() { const info = await this.#getInfo(); return this.#getField(info, "vars.sv_projectDesc") || ""; } async getPlayers() { try { await this.#ready; const response = await this.#fetch(`http://${this.#ip}/players.json`); if (!Array.isArray(response.data)) { throw new FiveMError("Unexpected response from players.json: expected an array.", { method: "getPlayers", url: `http://${this.#ip}/players.json` }); } this.#lastPlayerCount = response.data.length; return this.#lastPlayerCount; } catch (err) { if (err instanceof FiveMError) throw err; throw new FiveMError("Failed to fetch player count.", { method: "getPlayers", url: `http://${this.#ip}/players.json`, cause: err }); } } async getPlayersAll() { try { await this.#ready; const response = await this.#fetch(`http://${this.#ip}/players.json`); if (!Array.isArray(response.data)) { throw new FiveMError("Unexpected response from players.json: expected an array.", { method: "getPlayersAll", url: `http://${this.#ip}/players.json` }); } const players = response.data; this.#lastPlayerCount = players.length; return players; } catch (err) { if (err instanceof FiveMError) throw err; throw new FiveMError("Failed to fetch player list.", { method: "getPlayersAll", url: `http://${this.#ip}/players.json`, cause: err }); } } async getPlayer(query) { const players = await this.getPlayersAll(); if (typeof query === "number") { return players.find((p) => p.id === query) ?? null; } const lower = query.toLowerCase(); const idPrefixes = ["license:", "steam:", "discord:", "fivem:", "xbl:"]; if (idPrefixes.some((pfx) => lower.startsWith(pfx))) { return players.find((p) => p.identifiers.some((id) => id.toLowerCase() === lower)) ?? null; } return players.find((p) => p.name.toLowerCase().includes(lower)) ?? null; } async getMaxPlayers() { return Number((await this.#getInfo()).vars?.sv_maxClients || 0); } async getResources() { return (await this.#getInfo()).resources || []; } async getTags() { return (await this.#getInfo()).vars?.tags || ""; } async getOnesync() { return (await this.#getInfo()).vars?.onesync_enabled === "true"; } async getLocale() { return (await this.#getInfo()).vars?.locale || ""; } async getGamename() { return (await this.#getInfo()).vars?.gamename || ""; } async getSteamTicket() { return Boolean((await this.#getInfo()).requestSteamTicket); } async getGameBuild() { return Number((await this.#getInfo()).vars?.sv_enforceGameBuild || 0); } async getEnhancedHostSupport() { return (await this.#getInfo()).vars?.sv_enhancedHostSupport === "true"; } async getLicenseKeyToken() { return (await this.#getInfo()).vars?.sv_licenseKeyToken || ""; } async getScriptHookAllowed() { return (await this.#getInfo()).vars?.sv_scriptHookAllowed === "true"; } async getEndpoint() { return (await this.#getInfo()).server || null; } async getIcon() { return (await this.#getInfo()).icon || ""; } async getUpvotePower() { return Number((await this.#getInfo()).upvotePower || 0); } async getBurstPower() { return Number((await this.#getInfo()).boostPower || 0); } async getClients() { const dynamic = await this.#getDynamic(); if (dynamic) return dynamic.clients; this.#log("dynamic.json unavailable, falling back to players.json"); return this.getPlayers(); } async getHostname() { const dynamic = await this.#getDynamic(); if (dynamic) return dynamic.hostname; const info = await this.#getInfo(); return this.#getField(info, "vars.sv_projectName") || ""; } async getGametype() { const dynamic = await this.#getDynamic(); if (dynamic) return dynamic.gametype; return ""; } async getMapname() { const dynamic = await this.#getDynamic(); if (dynamic) return dynamic.mapname; return ""; } async getSvMaxclients() { const dynamic = await this.#getDynamic(); if (dynamic) return Number(dynamic.sv_maxclients) || 0; return Number((await this.#getInfo()).vars?.sv_maxClients || 0); } async getOwnerName() { return (await this.#getInfo()).ownerName || ""; } async getOwnerProfile() { return (await this.#getInfo()).ownerProfile || ""; } async getOwnerAvatar() { return (await this.#getInfo()).ownerAvatar || ""; } getIp() { return this.#ip; } watch(intervalMs, callback) { if (intervalMs < 100) { throw new FiveMError("Watch interval must be at least 100ms.", { method: "watch" }); } const id = setInterval(() => { callback(this); }, intervalMs); return { stop() { clearInterval(id); }, }; } static async multi(configs) { const servers = configs.map((c) => new Server(c.cfxre, c.options)); await Promise.allSettled(servers.map((s) => s.ready())); return { servers, async getAllStatus() { const map = new Map(); const results = await Promise.allSettled(servers.map(async (s) => { const ip = s.getIp(); const status = await s.getServerStatus(); map.set(ip, status); })); for (const [i, r] of results.entries()) { if (r.status === "rejected") { map.set(servers[i].getIp() || configs[i].cfxre, false); } } return map; }, async getAllPlayers() { const map = new Map(); const results = await Promise.allSettled(servers.map(async (s) => { const ip = s.getIp(); const count = await s.getPlayers(); map.set(ip, count); })); for (const [i, r] of results.entries()) { if (r.status === "rejected") { map.set(servers[i].getIp() || configs[i].cfxre, -1); } } return map; }, async getOnlineServers() { const results = await Promise.allSettled(servers.map(async (s) => { const status = await s.getServerStatus(); return status ? s : null; })); return results .filter((r) => r.status === "fulfilled" && r.value !== null) .map((r) => r.value); }, }; } }