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,

248 lines (247 loc) 9.59 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.searchServers = searchServers; exports.getServerByEndpoint = getServerByEndpoint; exports.getAllServers = getAllServers; exports.getServersByLocale = getServersByLocale; exports.getIconUrl = getIconUrl; exports.isPrivateServer = isPrivateServer; const protobufjs_1 = __importDefault(require("protobufjs")); const errors_js_1 = require("./errors.js"); const CFX_SERVERS_URL = "https://frontend.cfx-services.net/api/servers/streamRedir/"; const CFX_ICON_BASE = "https://frontend.cfx-services.net/api/servers/icon"; const PROTO_SCHEMA = ` syntax = "proto3"; package master; message Player { string name = 1; repeated string identifiers = 2; string endpoint = 3; int32 ping = 4; int32 id = 5; } message ServerData { int32 svMaxclients = 1; int32 clients = 2; int32 protocol = 3; string hostname = 4; string gametype = 5; string mapname = 6; repeated string resources = 8; string server = 9; repeated Player players = 10; int32 iconVersion = 11; map<string, string> vars = 12; bool enhancedHostSupport = 16; int32 upvotePower = 17; repeated string connectEndPoints = 18; int32 burstPower = 19; } message Server { string EndPoint = 1; ServerData Data = 2; } `; let ServerType; function getServerType() { if (!ServerType) { const root = protobufjs_1.default.parse(PROTO_SCHEMA).root; ServerType = root.lookupType("master.Server"); } return ServerType; } async function fetchAllServers(timeout = 30000, debug, maxResults, predicate) { const controller = new AbortController(); let timerId; const timeoutPromise = new Promise((_, reject) => { timerId = setTimeout(() => { controller.abort(); reject(new errors_js_1.FiveMError("Request timed out", { method: "searchServers", url: CFX_SERVERS_URL })); }, timeout); }); try { debug?.("Fetching server list from Cfx.re..."); const response = await Promise.race([ fetch(CFX_SERVERS_URL, { signal: controller.signal, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Accept": "*/*", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.6", "Origin": "https://servers.fivem.net", "Referer": "https://servers.fivem.net/", "Cache-Control": "no-cache, no-store", }, }), timeoutPromise, ]); if (!response.ok) { throw new errors_js_1.FiveMError(`Cfx.re returned status ${response.status}`, { method: "searchServers", url: CFX_SERVERS_URL, status: response.status }); } debug?.("Downloading server data..."); const buf = await Promise.race([response.arrayBuffer(), timeoutPromise]); debug?.(`Downloaded ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB`); const servers = []; const decode = getServerType(); const array = new Uint8Array(buf); let pos = 0; const hasLimit = maxResults && maxResults > 0; while (pos + 4 <= array.length) { const frameLength = (array[pos] | (array[pos + 1] << 8) | (array[pos + 2] << 16) | (array[pos + 3] << 24)) >>> 0; pos += 4; if (frameLength > 65535 || pos + frameLength > array.length) break; const frame = array.subarray(pos, pos + frameLength); pos += frameLength; try { const decoded = decode.decode(frame); if (!predicate || predicate(decoded)) { servers.push(decoded); if (hasLimit && servers.length >= maxResults) { debug?.(`Early stop: ${servers.length} matching results`); break; } } } catch { // skip malformed frames } } debug?.(`Decoded ${servers.length} servers`); return servers; } catch (err) { if (err instanceof errors_js_1.FiveMError) throw err; throw new errors_js_1.FiveMError("Failed to fetch server list from Cfx.re", { method: "searchServers", url: CFX_SERVERS_URL, cause: err }); } finally { if (timerId) clearTimeout(timerId); controller.abort(); } } function buildPredicate(filter) { const conditions = []; if (filter.query) { const target = filter.query.toLowerCase(); conditions.push((s) => { const d = s.Data; return ((d?.hostname || "").toLowerCase().includes(target) || (d?.vars?.sv_projectName || "").toLowerCase().includes(target) || (d?.vars?.tags || "").toLowerCase().includes(target) || (d?.gametype || "").toLowerCase().includes(target) || (d?.mapname || "").toLowerCase().includes(target)); }); } if (filter.locale) { const target = filter.locale.toLowerCase(); conditions.push((s) => (s.Data?.vars?.locale || "").toLowerCase() === target); } if (filter.hostname) { const target = filter.hostname.toLowerCase(); conditions.push((s) => { const name = s.Data?.hostname || ""; return name.toLowerCase().includes(target) || (s.Data?.vars?.sv_projectName || "").toLowerCase().includes(target); }); } if (filter.gametype) { const target = filter.gametype.toLowerCase(); conditions.push((s) => (s.Data?.gametype || "").toLowerCase().includes(target)); } if (filter.mapname) { const target = filter.mapname.toLowerCase(); conditions.push((s) => (s.Data?.mapname || "").toLowerCase().includes(target)); } if (filter.tag) { const target = filter.tag.toLowerCase(); conditions.push((s) => (s.Data?.vars?.tags || "").toLowerCase().includes(target)); } return (s) => conditions.every((c) => c(s)); } /** * Search FiveM servers from the Cfx.re global server list. * Stops decoding early when enough matching results are found. * * @param filter - Filter criteria (locale, hostname, gametype, mapname, tag, query) * @param limit - Maximum number of results to return (default: 20, pass 0 for unlimited) * @param timeout - Request timeout in ms (default: 30000) * @param offset - Number of results to skip before collecting (for pagination) * @returns Matching servers (resolved to plain objects) */ async function searchServers(filter, limit = 20, timeout, offset) { const predicate = filter ? buildPredicate(filter) : undefined; const needed = limit > 0 ? (offset || 0) + limit : 0; const servers = await fetchAllServers(timeout, undefined, needed || undefined, predicate); if (offset && offset > 0) { return servers.slice(offset); } return servers; } /** * Get a single server by its Cfx.re endpoint ID. * * @param endpoint - The server's unique endpoint ID (e.g. "3lamjz") * @param timeout - Request timeout in ms * @returns The matching server or null if not found */ async function getServerByEndpoint(endpoint, timeout) { const servers = await fetchAllServers(timeout); return servers.find((s) => s.EndPoint === endpoint) ?? null; } /** * Get all servers from the Cfx.re global server list. * * @param timeout - Request timeout in ms (default: 30000) * @returns All servers currently listed */ async function getAllServers(timeout) { return fetchAllServers(timeout); } /** * Get servers filtered by locale (e.g. "en-US", "de-DE"). * * @param locale - The locale code to match * @param timeout - Request timeout in ms * @param offset - Number of results to skip (for pagination) * @returns Servers matching the given locale (default: first 20) */ async function getServersByLocale(locale, timeout, offset) { return searchServers({ locale }, 20, timeout, offset); } /** * Build the icon URL for a server from the Cfx.re CDN. * Returns null when iconVersion is 0 (no custom icon set). * * @param endpointOrResult - Either a server endpoint ID (string) or a full SearchResult object * @param iconVersion - The icon version number (only needed if first arg is a string) * @returns The full icon image URL (PNG) or null if no icon available * * @example * const url = getIconUrl(result); // => "https://..." or null * const url = getIconUrl("3lamjz", 5); // => "https://..." */ function getIconUrl(endpointOrResult, iconVersion) { const ver = typeof endpointOrResult === "string" ? (iconVersion ?? 0) : (endpointOrResult.Data?.iconVersion ?? 0); if (!ver) return null; const ep = typeof endpointOrResult === "string" ? endpointOrResult : endpointOrResult.EndPoint; return `${CFX_ICON_BASE}/${ep}/${ver}.png`; } /** * Check if a server is private (IP hidden by Cfx.re). * Private servers have "private-placeholder.cfx.re" in connectEndPoints. */ function isPrivateServer(result) { const eps = result.Data?.connectEndPoints; if (!eps || eps.length === 0) return false; return eps.some((ep) => ep.includes("private-placeholder.cfx.re")); }