mirror of
https://github.com/minescope/mineping.git
synced 2025-07-14 08:04:35 +03:00
feat: implement SRV record lookup and improve ping logic
Add support for DNS SRV record lookups (`_minecraft._tcp`) to automatically resolve the correct server host and port, falling back to the provided address if no record is found. This makes server discovery compliant with standard Minecraft client behavior. The Java ping implementation has been refactored: - The `varint` module is rewritten to throw specific error codes and its `decodeVarInt` function now returns bytes read, which simplifies parsing logic. - The core ping logic is now promise-based and modular, breaking out packet creation and response processing into helper functions. - The TCP stream handler now robustly processes chunked data by catching recoverable decoder errors and waiting for more data, preventing crashes on incomplete packets. - Error handling is improved.
This commit is contained in:
330
lib/java.js
330
lib/java.js
@ -1,160 +1,222 @@
|
||||
/**
|
||||
* Implementation of the Java Minecraft ping protocol.
|
||||
* @see https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Server_List_Ping
|
||||
* @see https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import net from "node:net";
|
||||
import varint from "./varint.js";
|
||||
import dns from "node:dns/promises";
|
||||
import * as varint from "./varint.js";
|
||||
|
||||
/**
|
||||
* Ping a Minecraft Java server.
|
||||
* @param {string} host The host of the Java server.
|
||||
* @param {string} virtualHost The host sent in handshake.
|
||||
* @param {number} [port=25565] The port of the Java server.
|
||||
* @param {function} cb The callback function to handle the ping response.
|
||||
* @param {number} [timeout=5000] The timeout duration in milliseconds.
|
||||
* @param {number} [protocolVersion=-1] The protocol version of the Java client.
|
||||
* Represents the structured and user-friendly response from a server ping.
|
||||
* The fields and their optionality are based on the official protocol documentation.
|
||||
* @typedef {object} JavaPingResponse
|
||||
* @property {{ name: string, protocol: number }} version - Contains the server's version name and protocol number
|
||||
* @property {{ max: number, online: number, sample?: Array<{ name: string, id: string }> } | undefined} players - Player count and a sample of online players.
|
||||
* @property {object | string | undefined} description - Optional. The server's Message of the Day (MOTD)
|
||||
* @property {string | undefined} favicon - Optional. A Base64-encoded 64x64 PNG image data URI
|
||||
* @property {boolean | undefined} enforcesSecureChat - Optional. True if the server requires clients to have a Mojang-signed public key
|
||||
* @property {boolean | undefined} preventsChatReports - Optional. True if a mod is installed to disable chat reporting
|
||||
* @see {@link https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Status_Response}
|
||||
*/
|
||||
function ping(
|
||||
host,
|
||||
virtualHost,
|
||||
port = 25565,
|
||||
cb,
|
||||
timeout = 5000,
|
||||
protocolVersion = -1
|
||||
) {
|
||||
const socket = net.createConnection({ host, port });
|
||||
|
||||
// Set manual timeout interval.
|
||||
// This ensures the connection will NEVER hang regardless of internal state
|
||||
const timeoutTask = setTimeout(() => {
|
||||
socket.emit("error", new Error("Socket timeout"));
|
||||
}, timeout);
|
||||
/**
|
||||
* Creates the Handshake packet.
|
||||
* @param {string} host The hostname to connect to
|
||||
* @param {number} port The port to connect to
|
||||
* @param {number} protocolVersion The protocol version to use
|
||||
* @returns {Buffer} The complete Handshake packet
|
||||
*/
|
||||
function createHandshakePacket(host, port, protocolVersion) {
|
||||
const hostBuffer = varint.encodeString(host);
|
||||
|
||||
const closeSocket = () => {
|
||||
socket.destroy();
|
||||
clearTimeout(timeoutTask);
|
||||
};
|
||||
const payload = [
|
||||
varint.encodeVarInt(0x00), // Packet ID
|
||||
varint.encodeVarInt(protocolVersion),
|
||||
varint.encodeVarInt(hostBuffer.length),
|
||||
hostBuffer,
|
||||
varint.encodeUShort(port),
|
||||
varint.encodeVarInt(1), // Next state: 1 for Status
|
||||
];
|
||||
|
||||
// Generic error handler
|
||||
// This protects multiple error callbacks given the complex socket state
|
||||
// This is mostly dangerous since it can swallow errors
|
||||
let didFireError = false;
|
||||
|
||||
/**
|
||||
* Handle any error that occurs during the ping process.
|
||||
* @param {Error} err The error that occurred.
|
||||
*/
|
||||
const handleError = (err) => {
|
||||
if (!didFireError) {
|
||||
didFireError = true;
|
||||
closeSocket();
|
||||
cb(null, err);
|
||||
}
|
||||
};
|
||||
|
||||
// #setNoDelay instantly flushes data during read/writes
|
||||
// This prevents the runtime from delaying the write at all
|
||||
socket.setNoDelay(true);
|
||||
|
||||
socket.on("connect", () => {
|
||||
const handshake = varint.concat([
|
||||
varint.encodeInt(0),
|
||||
varint.encodeInt(protocolVersion),
|
||||
varint.encodeInt(virtualHost.length),
|
||||
varint.encodeString(virtualHost),
|
||||
varint.encodeUShort(port),
|
||||
varint.encodeInt(1),
|
||||
]);
|
||||
|
||||
socket.write(handshake);
|
||||
|
||||
const request = varint.concat([varint.encodeInt(0)]);
|
||||
|
||||
socket.write(request);
|
||||
});
|
||||
|
||||
let incomingBuffer = Buffer.alloc(0);
|
||||
|
||||
socket.on("data", (data) => {
|
||||
incomingBuffer = Buffer.concat([incomingBuffer, data]);
|
||||
|
||||
// Wait until incomingBuffer is at least 5 bytes long to ensure it has captured the first VarInt value
|
||||
// This value is used to determine the full read length of the response
|
||||
// "VarInts are never longer than 5 bytes"
|
||||
// https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Data_types#VarInt_and_VarLong
|
||||
if (incomingBuffer.length < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const packetLength = varint.decodeInt(incomingBuffer, offset);
|
||||
|
||||
// Ensure incomingBuffer contains the full response
|
||||
if (incomingBuffer.length - offset < packetLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packetId = varint.decodeInt(
|
||||
incomingBuffer,
|
||||
varint.decodeLength(packetLength)
|
||||
);
|
||||
|
||||
if (packetId === 0) {
|
||||
const data = incomingBuffer.subarray(
|
||||
varint.decodeLength(packetLength) + varint.decodeLength(packetId)
|
||||
);
|
||||
const responseLength = varint.decodeInt(data, 0);
|
||||
const response = data.subarray(
|
||||
varint.decodeLength(responseLength),
|
||||
varint.decodeLength(responseLength) + responseLength
|
||||
);
|
||||
|
||||
try {
|
||||
const message = JSON.parse(response);
|
||||
|
||||
closeSocket();
|
||||
cb(message, null);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
} else {
|
||||
handleError(new Error("Received unexpected packet"));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", handleError);
|
||||
return varint.concatPackets(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously ping Minecraft Java server.
|
||||
* The optional `options` argument can be an object with a `port` (default is `25565`) or/and `timeout` (default is `5000`) or/and `protocolVersion` (default is `-1`) property.
|
||||
* @param {string} host The Java server address.
|
||||
* @param {import('../types/index.js').PingOptions} options The configuration for pinging Minecraft Java server.
|
||||
* @returns {Promise<import('../types/index.js').JavaPingResponse>}
|
||||
* Creates the Status Request packet.
|
||||
* @returns {Buffer} The complete Status Request packet
|
||||
*/
|
||||
export function pingJava(host, options = {}) {
|
||||
if (!host) throw new Error("Host argument is not provided");
|
||||
function createStatusRequestPacket() {
|
||||
const payload = [
|
||||
varint.encodeVarInt(0x00), // Packet ID
|
||||
];
|
||||
return varint.concatPackets(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse the server status response from the buffer.
|
||||
* @param {Buffer} buffer The incoming data buffer
|
||||
* @returns {{ response: JavaPingResponse, remainder: Buffer } | null} The parsed response and the remaining buffer, or null if the packet is incomplete
|
||||
*/
|
||||
function processResponse(buffer) {
|
||||
let offset = 0;
|
||||
|
||||
try {
|
||||
const packetLengthResult = varint.decodeVarInt(buffer, offset);
|
||||
const packetLength = packetLengthResult.value;
|
||||
offset += packetLengthResult.bytesRead;
|
||||
|
||||
// Check if the full packet has arrived yet.
|
||||
if (buffer.length < offset + packetLength) {
|
||||
return null; // Incomplete packet, wait for more data.
|
||||
}
|
||||
|
||||
const packetIdResult = varint.decodeVarInt(buffer, offset);
|
||||
if (packetIdResult.value !== 0x00) {
|
||||
throw new Error(
|
||||
`Unexpected packet ID: ${packetIdResult.value}. Expected 0x00.`
|
||||
);
|
||||
}
|
||||
offset += packetIdResult.bytesRead;
|
||||
|
||||
const jsonLengthResult = varint.decodeVarInt(buffer, offset);
|
||||
const jsonLength = jsonLengthResult.value;
|
||||
offset += jsonLengthResult.bytesRead;
|
||||
|
||||
if (buffer.length < offset + jsonLength) {
|
||||
return null; // Incomplete JSON string, wait for more data.
|
||||
}
|
||||
|
||||
const jsonString = buffer
|
||||
.subarray(offset, offset + jsonLength)
|
||||
.toString("utf8");
|
||||
const response = JSON.parse(jsonString);
|
||||
|
||||
// Return the response and any data that came after this packet.
|
||||
const remainder = buffer.subarray(offset + jsonLength);
|
||||
|
||||
return { response, remainder };
|
||||
} catch (err) {
|
||||
// If the buffer is too short for a VarInt, it's a recoverable state.
|
||||
if (err.code === varint.ERR_VARINT_BUFFER_UNDERFLOW) {
|
||||
return null; // Wait for more data.
|
||||
}
|
||||
// For malformed VarInts or JSON, throw the error to reject the promise.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pings a Minecraft Java Edition server.
|
||||
* This function performs an SRV lookup and then attempts to connect and retrieve the server status.
|
||||
* @param {string} host The server address to ping
|
||||
* @param {object} [options={}] Optional configuration
|
||||
* @param {number} [options.port=25565] The fallback port if an SRV record is not found
|
||||
* @param {number} [options.timeout=5000] The connection timeout in milliseconds
|
||||
* @param {number} [options.protocolVersion=-1] The protocol version to use in the handshake. `-1` is for auto-detection
|
||||
* @returns {Promise<JavaPingResponse>} A promise that resolves with the server's status
|
||||
*/
|
||||
export async function pingJava(host, options = {}) {
|
||||
if (typeof host !== "string" || host.trim() === "") {
|
||||
throw new Error("Host argument is required.");
|
||||
}
|
||||
|
||||
const {
|
||||
port = 25565,
|
||||
port: fallbackPort = 25565,
|
||||
timeout = 5000,
|
||||
protocolVersion = -1,
|
||||
virtualHost = null,
|
||||
} = options;
|
||||
|
||||
let targetHost = host;
|
||||
let targetPort = fallbackPort;
|
||||
|
||||
try {
|
||||
const srvRecords = await dns.resolveSrv(`_minecraft._tcp.${host}`);
|
||||
if (srvRecords.length > 0) {
|
||||
targetHost = srvRecords[0].name;
|
||||
targetPort = srvRecords[0].port;
|
||||
}
|
||||
} catch (err) {
|
||||
// Common errors like ENODATA or ENOTFOUND are expected when a server
|
||||
// does not have an SRV record, so we ignore them and proceed.
|
||||
if (!["ENODATA", "ENOTFOUND"].includes(err.code)) {
|
||||
// For other errors we should re-throw.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ping(
|
||||
host,
|
||||
virtualHost || host,
|
||||
port,
|
||||
(res, err) => {
|
||||
err ? reject(err) : resolve(res);
|
||||
},
|
||||
timeout,
|
||||
protocolVersion
|
||||
);
|
||||
const socket = net.createConnection({ host: targetHost, port: targetPort });
|
||||
|
||||
// Prevent cleanup tasks from running more than once
|
||||
// in case of multiple error callbacks
|
||||
let isCleanupCompleted = false;
|
||||
|
||||
// Set a manual timeout interval to ensure
|
||||
// the connection will NEVER hang regardless of internal state
|
||||
const timeoutTask = setTimeout(() => {
|
||||
socket.emit("error", new Error("Socket timeout"));
|
||||
}, timeout);
|
||||
|
||||
// Idempotent function to handle cleanup tasks, we can safely call it multiple times without side effects
|
||||
const cleanup = () => {
|
||||
if (isCleanupCompleted) return;
|
||||
isCleanupCompleted = true;
|
||||
clearTimeout(timeoutTask);
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
// #setNoDelay instantly flushes data during read/writes
|
||||
// This prevents the runtime from delaying the write at all
|
||||
socket.setNoDelay(true);
|
||||
|
||||
// Generic error handler
|
||||
socket.on("error", (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
if (!isCleanupCompleted) {
|
||||
cleanup();
|
||||
reject(new Error("Socket closed unexpectedly without a response."));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
try {
|
||||
const handshakePacket = createHandshakePacket(
|
||||
host,
|
||||
targetPort,
|
||||
protocolVersion
|
||||
);
|
||||
const statusRequestPacket = createStatusRequestPacket();
|
||||
socket.write(handshakePacket);
|
||||
socket.write(statusRequestPacket);
|
||||
} catch (err) {
|
||||
// Handle synchronous errors during packet creation/writing
|
||||
socket.emit("error", err);
|
||||
}
|
||||
});
|
||||
|
||||
let incomingBuffer = Buffer.alloc(0);
|
||||
|
||||
socket.on("data", (data) => {
|
||||
incomingBuffer = Buffer.concat([incomingBuffer, data]);
|
||||
|
||||
try {
|
||||
const result = processResponse(incomingBuffer);
|
||||
if (result) {
|
||||
// We successfully parsed a response. Clean up before resolving.
|
||||
cleanup();
|
||||
resolve(result.response);
|
||||
}
|
||||
// If result is null, we just wait for more data to arrive.
|
||||
} catch (err) {
|
||||
socket.emit("error", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user