mirror of
https://github.com/minescope/mineping.git
synced 2025-07-03 16:28:15 +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:
parent
51b4771305
commit
c7b99cb6db
310
lib/java.js
310
lib/java.js
@ -1,79 +1,204 @@
|
|||||||
/**
|
/**
|
||||||
* Implementation of the Java Minecraft ping protocol.
|
* 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";
|
"use strict";
|
||||||
|
|
||||||
import net from "node:net";
|
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.
|
* Represents the structured and user-friendly response from a server ping.
|
||||||
* @param {string} host The host of the Java server.
|
* The fields and their optionality are based on the official protocol documentation.
|
||||||
* @param {string} virtualHost The host sent in handshake.
|
* @typedef {object} JavaPingResponse
|
||||||
* @param {number} [port=25565] The port of the Java server.
|
* @property {{ name: string, protocol: number }} version - Contains the server's version name and protocol number
|
||||||
* @param {function} cb The callback function to handle the ping response.
|
* @property {{ max: number, online: number, sample?: Array<{ name: string, id: string }> } | undefined} players - Player count and a sample of online players.
|
||||||
* @param {number} [timeout=5000] The timeout duration in milliseconds.
|
* @property {object | string | undefined} description - Optional. The server's Message of the Day (MOTD)
|
||||||
* @param {number} [protocolVersion=-1] The protocol version of the Java client.
|
* @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
|
* 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 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
|
||||||
|
];
|
||||||
|
|
||||||
|
return varint.concatPackets(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the Status Request packet.
|
||||||
|
* @returns {Buffer} The complete Status Request packet
|
||||||
|
*/
|
||||||
|
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: fallbackPort = 25565,
|
||||||
|
timeout = 5000,
|
||||||
|
protocolVersion = -1,
|
||||||
|
} = 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) => {
|
||||||
|
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(() => {
|
const timeoutTask = setTimeout(() => {
|
||||||
socket.emit("error", new Error("Socket timeout"));
|
socket.emit("error", new Error("Socket timeout"));
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
const closeSocket = () => {
|
// Idempotent function to handle cleanup tasks, we can safely call it multiple times without side effects
|
||||||
socket.destroy();
|
const cleanup = () => {
|
||||||
|
if (isCleanupCompleted) return;
|
||||||
|
isCleanupCompleted = true;
|
||||||
clearTimeout(timeoutTask);
|
clearTimeout(timeoutTask);
|
||||||
};
|
socket.destroy();
|
||||||
|
|
||||||
// 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
|
// #setNoDelay instantly flushes data during read/writes
|
||||||
// This prevents the runtime from delaying the write at all
|
// This prevents the runtime from delaying the write at all
|
||||||
socket.setNoDelay(true);
|
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", () => {
|
socket.on("connect", () => {
|
||||||
const handshake = varint.concat([
|
try {
|
||||||
varint.encodeInt(0),
|
const handshakePacket = createHandshakePacket(
|
||||||
varint.encodeInt(protocolVersion),
|
host,
|
||||||
varint.encodeInt(virtualHost.length),
|
targetPort,
|
||||||
varint.encodeString(virtualHost),
|
protocolVersion
|
||||||
varint.encodeUShort(port),
|
);
|
||||||
varint.encodeInt(1),
|
const statusRequestPacket = createStatusRequestPacket();
|
||||||
]);
|
socket.write(handshakePacket);
|
||||||
|
socket.write(statusRequestPacket);
|
||||||
socket.write(handshake);
|
} catch (err) {
|
||||||
|
// Handle synchronous errors during packet creation/writing
|
||||||
const request = varint.concat([varint.encodeInt(0)]);
|
socket.emit("error", err);
|
||||||
|
}
|
||||||
socket.write(request);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let incomingBuffer = Buffer.alloc(0);
|
let incomingBuffer = Buffer.alloc(0);
|
||||||
@ -81,80 +206,17 @@ function ping(
|
|||||||
socket.on("data", (data) => {
|
socket.on("data", (data) => {
|
||||||
incomingBuffer = Buffer.concat([incomingBuffer, 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 {
|
try {
|
||||||
const message = JSON.parse(response);
|
const result = processResponse(incomingBuffer);
|
||||||
|
if (result) {
|
||||||
closeSocket();
|
// We successfully parsed a response. Clean up before resolving.
|
||||||
cb(message, null);
|
cleanup();
|
||||||
|
resolve(result.response);
|
||||||
|
}
|
||||||
|
// If result is null, we just wait for more data to arrive.
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
socket.emit("error", err);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handleError(new Error("Received unexpected packet"));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("error", handleError);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>}
|
|
||||||
*/
|
|
||||||
export function pingJava(host, options = {}) {
|
|
||||||
if (!host) throw new Error("Host argument is not provided");
|
|
||||||
|
|
||||||
const {
|
|
||||||
port = 25565,
|
|
||||||
timeout = 5000,
|
|
||||||
protocolVersion = -1,
|
|
||||||
virtualHost = null,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ping(
|
|
||||||
host,
|
|
||||||
virtualHost || host,
|
|
||||||
port,
|
|
||||||
(res, err) => {
|
|
||||||
err ? reject(err) : resolve(res);
|
|
||||||
},
|
|
||||||
timeout,
|
|
||||||
protocolVersion
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
189
lib/varint.js
189
lib/varint.js
@ -1,19 +1,34 @@
|
|||||||
// https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Protocol#Data_types
|
// https://minecraft.wiki/w/Java_Edition_protocol/Data_types
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
export const ERR_VARINT_BUFFER_UNDERFLOW = "VARINT_BUFFER_UNDERFLOW";
|
||||||
|
export const ERR_VARINT_MALFORMED = "VARINT_MALFORMED";
|
||||||
|
export const ERR_VARINT_ENCODE_TOO_LARGE = "VARINT_ENCODE_TOO_LARGE";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility object for encoding and decoding varints.
|
* Creates a custom error object with a code property.
|
||||||
|
* @param {string} message The error message
|
||||||
|
* @param {string} code The error code
|
||||||
|
* @returns {Error}
|
||||||
*/
|
*/
|
||||||
const varint = {
|
function createVarIntError(message, code) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.code = code;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes an integer value into a varint byte buffer.
|
* Encodes an integer into a VarInt buffer.
|
||||||
* @param {number} val - The integer value to encode.
|
* VarInts are never longer than 5 bytes for the Minecraft protocol.
|
||||||
* @returns {Buffer}
|
* @param {number} value The integer to encode
|
||||||
|
* @returns {Buffer} The encoded VarInt as a buffer
|
||||||
|
* @throws {Error} if the value is too large to be encoded
|
||||||
*/
|
*/
|
||||||
encodeInt: (val) => {
|
export function encodeVarInt(value) {
|
||||||
// "VarInts are never longer than 5 bytes"
|
|
||||||
// https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Data_types#VarInt_and_VarLong
|
|
||||||
const buf = Buffer.alloc(5);
|
const buf = Buffer.alloc(5);
|
||||||
let written = 0;
|
let written = 0;
|
||||||
|
let val = value;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const byte = val & 0x7f;
|
const byte = val & 0x7f;
|
||||||
@ -25,99 +40,99 @@ const varint = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buf.writeUInt8(byte | 0x80, written++);
|
buf.writeUInt8(byte | 0x80, written++);
|
||||||
|
|
||||||
|
if (written >= 5 && val > 0) {
|
||||||
|
throw createVarIntError(
|
||||||
|
"Value too large for a 5-byte VarInt",
|
||||||
|
ERR_VARINT_ENCODE_TOO_LARGE
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.subarray(0, written);
|
return buf.subarray(0, written);
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes a string value into a UTF-8 byte buffer.
|
|
||||||
* @param {string} val - The string value to encode.
|
|
||||||
* @returns {Buffer}
|
|
||||||
*/
|
|
||||||
encodeString: (val) => {
|
|
||||||
return Buffer.from(val, "utf-8");
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes an unsigned short value into a byte buffer.
|
|
||||||
* @param {number} val - The unsigned short value to encode.
|
|
||||||
* @returns {Buffer}
|
|
||||||
*/
|
|
||||||
encodeUShort: (val) => {
|
|
||||||
return Buffer.from([val >> 8, val & 0xff]);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concatenates multiple byte buffers into a single byte buffer.
|
|
||||||
* @param {Buffer[]} chunks - An array of byte buffers to concatenate.
|
|
||||||
* @returns {Buffer}
|
|
||||||
*/
|
|
||||||
concat: (chunks) => {
|
|
||||||
let length = 0;
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
length += chunk.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = [varint.encodeInt(length), ...chunks];
|
/**
|
||||||
|
* Encodes a string into a UTF-8 buffer.
|
||||||
return Buffer.concat(buffer);
|
* @param {string} value The string to encode
|
||||||
},
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
export function encodeString(value) {
|
||||||
|
return Buffer.from(value, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes a varint integer value from a buffer.
|
* Encodes an unsigned short (16-bit big-endian) into a 2-byte buffer.
|
||||||
* @param {Buffer} buffer - The byte buffer to decode from.
|
* @param {number} value The number to encode
|
||||||
* @param {number} offset - The offset in the buffer to start decoding from.
|
* @returns {Buffer}
|
||||||
* @returns {number}
|
|
||||||
*/
|
*/
|
||||||
decodeInt: (buffer, offset) => {
|
export function encodeUShort(value) {
|
||||||
// Fast path for single-byte varints
|
const buf = Buffer.alloc(2);
|
||||||
|
buf.writeUInt16BE(value, 0);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Minecraft-style packet by concatenating chunks and prefixing the total length as a VarInt.
|
||||||
|
* @param {Buffer[]} chunks An array of buffers to include in the packet payload
|
||||||
|
* @returns {Buffer} The complete packet with its length prefix
|
||||||
|
*/
|
||||||
|
export function concatPackets(chunks) {
|
||||||
|
const payload = Buffer.concat(chunks);
|
||||||
|
const lengthPrefix = encodeVarInt(payload.length);
|
||||||
|
return Buffer.concat([lengthPrefix, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a VarInt from a buffer.
|
||||||
|
* Returns the decoded value and the number of bytes it consumed.
|
||||||
|
* @param {Buffer} buffer The buffer to read from
|
||||||
|
* @param {number} [offset=0] The starting offset in the buffer
|
||||||
|
* @returns {{ value: number, bytesRead: number }}
|
||||||
|
* @throws {Error} if the buffer is too short or the VarInt is malformed
|
||||||
|
*/
|
||||||
|
export function decodeVarInt(buffer, offset = 0) {
|
||||||
|
if (offset >= buffer.length) {
|
||||||
|
throw createVarIntError(
|
||||||
|
"Buffer underflow: Cannot decode VarInt at or beyond buffer length.",
|
||||||
|
ERR_VARINT_BUFFER_UNDERFLOW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path for single-byte VarInts, which are very common.
|
||||||
const firstByte = buffer.readUInt8(offset);
|
const firstByte = buffer.readUInt8(offset);
|
||||||
if (firstByte < 0x80) {
|
if ((firstByte & 0x80) === 0) {
|
||||||
return firstByte;
|
return { value: firstByte, bytesRead: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
let val = firstByte & 0x7f;
|
let val = firstByte & 0x7f; // Get the first 7 bits
|
||||||
let position = 7;
|
let position = 7; // Bit position for the next byte's data
|
||||||
|
let bytesRead = 1; // We've read one byte so far
|
||||||
|
let currentOffset = offset + 1; // Start reading from the next
|
||||||
|
|
||||||
|
// Max 4 more bytes (total 5 bytes for a VarInt)
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (currentOffset >= buffer.length) {
|
||||||
|
throw createVarIntError(
|
||||||
|
"Buffer underflow: Incomplete VarInt, expected more bytes.",
|
||||||
|
ERR_VARINT_BUFFER_UNDERFLOW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byte = buffer.readUInt8(currentOffset);
|
||||||
|
bytesRead++;
|
||||||
|
currentOffset++;
|
||||||
|
|
||||||
while (position < 32) {
|
|
||||||
const byte = buffer.readUInt8(++offset);
|
|
||||||
val |= (byte & 0x7f) << position;
|
val |= (byte & 0x7f) << position;
|
||||||
|
position += 7;
|
||||||
|
|
||||||
if ((byte & 0x80) === 0) {
|
if ((byte & 0x80) === 0) {
|
||||||
return val;
|
return { value: val, bytesRead: bytesRead };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
position += 7;
|
throw createVarIntError(
|
||||||
|
"VarInt is too big or malformed: 5 bytes read with continuation bit still set.",
|
||||||
|
ERR_VARINT_MALFORMED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("VarInt is too big");
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates how many bytes are needed to encode a number as a VarInt
|
|
||||||
* VarInts use a variable number of bytes to efficiently encode integers
|
|
||||||
* Each byte uses 7 bits for the value and 1 bit to indicate if more bytes follow
|
|
||||||
* VarInts are never longer than 5 bytes
|
|
||||||
*
|
|
||||||
* @param {number} val - The number to calculate the VarInt length for
|
|
||||||
* @returns {1|2|3|4|5} The number of bytes needed to encode the value
|
|
||||||
*/
|
|
||||||
decodeLength: (val) => {
|
|
||||||
// Using bit shifts to calculate power of 2 thresholds
|
|
||||||
// 1 << 7 = 2^7 = 128 - Numbers below this fit in 1 byte
|
|
||||||
// 1 << 14 = 2^14 = 16,384 - Numbers below this fit in 2 bytes
|
|
||||||
// 1 << 21 = 2^21 = 2,097,152 - Numbers below this fit in 3 bytes
|
|
||||||
// 1 << 28 = 2^28 = 268,435,456 - Numbers below this fit in 4 bytes
|
|
||||||
// Any larger number needs 5 bytes (maximum VarInt size)
|
|
||||||
|
|
||||||
if (val < 1 << 7) return 1;
|
|
||||||
if (val < 1 << 14) return 2;
|
|
||||||
if (val < 1 << 21) return 3;
|
|
||||||
if (val < 1 << 28) return 4;
|
|
||||||
return 5;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default varint;
|
|
||||||
|
@ -1,112 +1,133 @@
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { pingJava } from "../lib/java.js";
|
import { pingJava } from "../lib/java.js";
|
||||||
import varint from "../lib/varint.js";
|
import * as varint from "../lib/varint.js";
|
||||||
|
|
||||||
vi.mock("node:net");
|
vi.mock("node:net");
|
||||||
|
vi.mock("node:dns/promises");
|
||||||
|
|
||||||
describe("pingJava", () => {
|
describe("pingJava", () => {
|
||||||
let mockSocket;
|
let mockSocket;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Simulate no SRV record found.
|
||||||
|
dns.resolveSrv.mockResolvedValue([]);
|
||||||
|
|
||||||
const mockHandlers = {};
|
const mockHandlers = {};
|
||||||
mockSocket = {
|
mockSocket = {
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
destroy: vi.fn(),
|
// Make `destroy` emit 'error' if an error is passed.
|
||||||
|
destroy: vi.fn((err) => {
|
||||||
|
if (err) {
|
||||||
|
mockSocket.emit("error", err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
setNoDelay: vi.fn(),
|
setNoDelay: vi.fn(),
|
||||||
on: vi.fn((event, handler) => (mockHandlers[event] = handler)),
|
on: vi.fn((event, handler) => (mockHandlers[event] = handler)),
|
||||||
emit: vi.fn((event, ...args) => mockHandlers[event]?.(...args)),
|
emit: vi.fn((event, ...args) => mockHandlers[event]?.(...args)),
|
||||||
};
|
};
|
||||||
net.createConnection = vi.fn().mockReturnValue(mockSocket);
|
net.createConnection.mockReturnValue(mockSocket);
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should ping a server and handle a chunked response", async () => {
|
it("should ping a server and handle a chunked response", async () => {
|
||||||
const host = "mc.hypixel.net";
|
const host = "mc.hypixel.net";
|
||||||
const options = {
|
const options = { port: 25565 };
|
||||||
port: 25565,
|
|
||||||
timeout: 5000,
|
|
||||||
protocolVersion: 765,
|
|
||||||
virtualHost: "mc.hypixel.net",
|
|
||||||
};
|
|
||||||
|
|
||||||
const pingPromise = pingJava(host, options);
|
const pingPromise = pingJava(host, options);
|
||||||
|
|
||||||
mockSocket.emit("connect");
|
// Allow the async SRV lookup to complete
|
||||||
|
await vi.runAllTicks();
|
||||||
|
|
||||||
|
expect(dns.resolveSrv).toHaveBeenCalledWith(`_minecraft._tcp.${host}`);
|
||||||
expect(net.createConnection).toHaveBeenCalledWith({
|
expect(net.createConnection).toHaveBeenCalledWith({
|
||||||
host,
|
host,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
});
|
});
|
||||||
expect(mockSocket.setNoDelay).toHaveBeenCalledWith(true);
|
|
||||||
|
mockSocket.emit("connect");
|
||||||
expect(mockSocket.write).toHaveBeenCalledTimes(2);
|
expect(mockSocket.write).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
version: { name: "1.21", protocol: 765 },
|
version: { name: "1.21", protocol: 765 },
|
||||||
players: { max: 20, online: 5, sample: [] },
|
players: { max: 20, online: 5, sample: [] },
|
||||||
description: "A Minecraft Server",
|
description: "A Minecraft Server",
|
||||||
favicon: "data:image/png;base64,iVBORw0KGgo...",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fullPacket = createMockJavaResponse(mockResponse);
|
const fullPacket = createMockJavaResponse(mockResponse);
|
||||||
const chunk1 = fullPacket.subarray(0, 10);
|
const chunk1 = fullPacket.subarray(0, 10);
|
||||||
const chunk2 = fullPacket.subarray(10);
|
const chunk2 = fullPacket.subarray(10);
|
||||||
|
|
||||||
|
// Simulate receiving data in chunks
|
||||||
mockSocket.emit("data", chunk1);
|
mockSocket.emit("data", chunk1);
|
||||||
mockSocket.emit("data", chunk2);
|
mockSocket.emit("data", chunk2);
|
||||||
|
|
||||||
const result = await pingPromise;
|
const result = await pingPromise;
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockResponse);
|
||||||
expect(mockSocket.destroy).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("errors", () => {
|
describe("errors", () => {
|
||||||
it("should throw an error if host is not provided", () => {
|
it("should throw an error if host is not provided", async () => {
|
||||||
expect(() => pingJava(null)).toThrow("Host argument is not provided");
|
await expect(pingJava(null)).rejects.toThrow("Host argument is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject on socket timeout before data is received", async () => {
|
it("should reject on socket timeout", async () => {
|
||||||
const pingPromise = pingJava("localhost", { timeout: 1000 });
|
const pingPromise = pingJava("localhost", { timeout: 1000 });
|
||||||
|
await vi.runAllTicks();
|
||||||
mockSocket.emit("connect");
|
mockSocket.emit("connect");
|
||||||
|
|
||||||
// Advance time to trigger the timeout
|
|
||||||
vi.advanceTimersByTime(1000);
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
await expect(pingPromise).rejects.toThrow("Socket timeout");
|
await expect(pingPromise).rejects.toThrow("Socket timeout");
|
||||||
expect(mockSocket.destroy).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject on connection error", async () => {
|
it("should reject on connection error", async () => {
|
||||||
const pingPromise = pingJava("localhost");
|
const pingPromise = pingJava("localhost");
|
||||||
|
await vi.runAllTicks();
|
||||||
// Simulate a connection refusal
|
|
||||||
mockSocket.emit("error", new Error("ECONNREFUSED"));
|
mockSocket.emit("error", new Error("ECONNREFUSED"));
|
||||||
|
|
||||||
await expect(pingPromise).rejects.toThrow("ECONNREFUSED");
|
await expect(pingPromise).rejects.toThrow("ECONNREFUSED");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject if the socket closes prematurely without a response", async () => {
|
||||||
|
const pingPromise = pingJava("localhost");
|
||||||
|
|
||||||
|
// Allow the initial async operations to complete
|
||||||
|
await vi.runAllTicks();
|
||||||
|
|
||||||
|
// Simulate the server accepting the connection and then immediately closing it
|
||||||
|
mockSocket.emit("connect");
|
||||||
|
mockSocket.emit("close");
|
||||||
|
|
||||||
|
// The promise should reject with our specific 'close' handler message
|
||||||
|
await expect(pingPromise).rejects.toThrow(
|
||||||
|
"Socket closed unexpectedly without a response."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should only reject once, even if multiple errors occur", async () => {
|
it("should only reject once, even if multiple errors occur", async () => {
|
||||||
const pingPromise = pingJava("localhost");
|
const pingPromise = pingJava("localhost");
|
||||||
|
await vi.runAllTicks();
|
||||||
// Fire two errors back-to-back
|
|
||||||
mockSocket.emit("error", new Error("First error"));
|
mockSocket.emit("error", new Error("First error"));
|
||||||
mockSocket.emit("error", new Error("Second error"));
|
mockSocket.emit("error", new Error("Second error")); // Should be ignored
|
||||||
|
|
||||||
await expect(pingPromise).rejects.toThrow("First error");
|
await expect(pingPromise).rejects.toThrow("First error");
|
||||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock Java status response packet according to the protocol.
|
||||||
|
* Structure: [Overall Length] [Packet ID] [JSON Length] [JSON String]
|
||||||
|
* @param {object} response The JSON response object
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
function createMockJavaResponse(response) {
|
function createMockJavaResponse(response) {
|
||||||
const jsonString = JSON.stringify(response);
|
const jsonString = JSON.stringify(response);
|
||||||
const jsonBuffer = Buffer.from(jsonString, "utf8");
|
const jsonBuffer = varint.encodeString(jsonString);
|
||||||
const responseLength = varint.encodeInt(jsonBuffer.length);
|
const jsonLength = varint.encodeVarInt(jsonBuffer.length);
|
||||||
const packetId = varint.encodeInt(0);
|
const packetId = varint.encodeVarInt(0x00);
|
||||||
const packetData = Buffer.concat([packetId, responseLength, jsonBuffer]);
|
const payloadParts = [packetId, jsonLength, jsonBuffer];
|
||||||
const packetLength = varint.encodeInt(packetData.length);
|
return varint.concatPackets(payloadParts);
|
||||||
return Buffer.concat([packetLength, packetData]);
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import varint from "../lib/varint.js";
|
import * as varint from "../lib/varint.js";
|
||||||
|
|
||||||
describe("varint.js", () => {
|
describe("varint.js", () => {
|
||||||
it("should encode and decode integers symmetrically (round-trip)", () => {
|
it("should encode and decode integers symmetrically (round-trip)", () => {
|
||||||
@ -16,8 +16,8 @@ describe("varint.js", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
testValues.forEach((value) => {
|
testValues.forEach((value) => {
|
||||||
const encoded = varint.encodeInt(value);
|
const encoded = varint.encodeVarInt(value);
|
||||||
const decoded = varint.decodeInt(encoded, 0);
|
const { value: decoded } = varint.decodeVarInt(encoded, 0);
|
||||||
expect(decoded, `Value ${value} failed round-trip`).toBe(value);
|
expect(decoded, `Value ${value} failed round-trip`).toBe(value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -25,52 +25,37 @@ describe("varint.js", () => {
|
|||||||
it("should decode an integer from a non-zero offset", () => {
|
it("should decode an integer from a non-zero offset", () => {
|
||||||
// [255 (invalid varint), 128 (valid varint), 127 (valid varint)]
|
// [255 (invalid varint), 128 (valid varint), 127 (valid varint)]
|
||||||
const buffer = Buffer.from([0xff, 0x80, 0x01, 0x7f]);
|
const buffer = Buffer.from([0xff, 0x80, 0x01, 0x7f]);
|
||||||
expect(varint.decodeInt(buffer, 1)).toBe(128);
|
const { value: decoded } = varint.decodeVarInt(buffer, 1);
|
||||||
|
expect(decoded).toBe(128);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error for a malformed varint that is too long", () => {
|
it("should throw an error for a malformed varint that is too long", () => {
|
||||||
const invalidBuffer = Buffer.from([0x80, 0x80, 0x80, 0x80, 0x80, 0x80]);
|
const invalidBuffer = Buffer.from([0x80, 0x80, 0x80, 0x80, 0x80, 0x80]);
|
||||||
expect(() => varint.decodeInt(invalidBuffer, 0)).toThrow(
|
expect(() => varint.decodeVarInt(invalidBuffer, 0)).toThrow(
|
||||||
"VarInt is too big"
|
"VarInt is too big or malformed"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should correctly predict the encoded length of a varint", () => {
|
|
||||||
const boundaries = [0, 127, 128, 16383, 16384, 2097151, 2097152];
|
|
||||||
boundaries.forEach((value) => {
|
|
||||||
const predictedLength = varint.decodeLength(value);
|
|
||||||
const actualLength = varint.encodeInt(value).length;
|
|
||||||
expect(predictedLength).toBe(actualLength);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should encode 16-bit unsigned shorts in big-endian format", () => {
|
it("should encode 16-bit unsigned shorts in big-endian format", () => {
|
||||||
expect(varint.encodeUShort(0)).toEqual(Buffer.from([0x00, 0x00]));
|
expect(varint.encodeUShort(0)).toEqual(Buffer.from([0x00, 0x00]));
|
||||||
expect(varint.encodeUShort(256)).toEqual(Buffer.from([0x01, 0x00]));
|
expect(varint.encodeUShort(256)).toEqual(Buffer.from([0x01, 0x00]));
|
||||||
expect(varint.encodeUShort(65535)).toEqual(Buffer.from([0xff, 0xff]));
|
expect(varint.encodeUShort(65535)).toEqual(Buffer.from([0xff, 0xff]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should correctly assemble and parse a Minecraft handshake packet", () => {
|
it("should correctly assemble a Minecraft packet with a length prefix", () => {
|
||||||
const protocolVersion = -1;
|
const payloadParts = [
|
||||||
const virtualHost = "mc.example.com";
|
varint.encodeVarInt(0), // protocol
|
||||||
const port = 25565;
|
varint.encodeString("mc.example.com"), // host
|
||||||
|
varint.encodeUShort(25565), // port
|
||||||
const payload = Buffer.concat([
|
];
|
||||||
varint.encodeInt(0),
|
const payload = Buffer.concat(payloadParts);
|
||||||
varint.encodeInt(protocolVersion),
|
const finalPacket = varint.concatPackets(payloadParts);
|
||||||
varint.encodeInt(virtualHost.length),
|
const { value: decodedPacketLength, bytesRead } = varint.decodeVarInt(
|
||||||
varint.encodeString(virtualHost),
|
finalPacket,
|
||||||
varint.encodeUShort(port),
|
0
|
||||||
varint.encodeInt(1),
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
const finalPacket = varint.concat([payload]);
|
|
||||||
|
|
||||||
const decodedPacketLength = varint.decodeInt(finalPacket, 0);
|
|
||||||
expect(decodedPacketLength).toBe(payload.length);
|
expect(decodedPacketLength).toBe(payload.length);
|
||||||
|
const decodedPayload = finalPacket.subarray(bytesRead);
|
||||||
const lengthOfPacketLength = varint.decodeLength(decodedPacketLength);
|
|
||||||
const decodedPayload = finalPacket.subarray(lengthOfPacketLength);
|
|
||||||
expect(decodedPayload).toEqual(payload);
|
expect(decodedPayload).toEqual(payload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user