mirror of
https://github.com/minescope/mineping.git
synced 2025-07-18 17:26:37 +03:00
Compare commits
3 Commits
51b4771305
...
a1b999ca4e
Author | SHA1 | Date | |
---|---|---|---|
a1b999ca4e
|
|||
7322034aba
|
|||
c7b99cb6db
|
@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
import dgram from "node:dgram";
|
import dgram from "node:dgram";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import createDebug from "debug";
|
||||||
|
|
||||||
|
const debug = createDebug("mineping:bedrock");
|
||||||
|
|
||||||
const MAGIC = "00ffff00fefefefefdfdfdfd12345678";
|
const MAGIC = "00ffff00fefefefefdfdfdfd12345678";
|
||||||
const START_TIME = Date.now();
|
const START_TIME = Date.now();
|
||||||
@ -15,44 +18,50 @@ const UNCONNECTED_PONG = 0x1c;
|
|||||||
/**
|
/**
|
||||||
* Representation of raw, semicolon-delimited MOTD string.
|
* Representation of raw, semicolon-delimited MOTD string.
|
||||||
* This struct directly mirrors the fields and order from the server response.
|
* This struct directly mirrors the fields and order from the server response.
|
||||||
* @see {@link https://minecraft.wiki/w/RakNet#Unconnected_Pong}
|
* See [`Unconnected Pong Documentation`](https://minecraft.wiki/w/RakNet#Unconnected_Pong) for more details.
|
||||||
* @typedef {object} BedrockMotd
|
* @typedef {object} BedrockMotd
|
||||||
* @property {string} edition - The edition of the server (MCPE or MCEE)
|
* @property {string} edition - The edition of the server (MCPE or MCEE).
|
||||||
* @property {string} name - The primary name of the server (first line of MOTD)
|
* @property {string} name - The primary name of the server (first line of MOTD).
|
||||||
* @property {number} protocol - The protocol version
|
* @property {number} protocol - The protocol version.
|
||||||
* @property {string} version - The game version (e.g., "1.21.2")
|
* @property {string} version - The game version (e.g., "1.21.2").
|
||||||
* @property {number} playerCount - The current number of players online
|
* @property {number} playerCount - The current number of players online.
|
||||||
* @property {number} playerMax - The maximum number of players allowed
|
* @property {number} playerMax - The maximum number of players allowed.
|
||||||
* @property {bigint} serverGuid - The server's GUID
|
* @property {bigint} serverGuid - The server's GUID.
|
||||||
* @property {string} subName - The secondary name of the server (second line of MOTD)
|
* @property {string} subName - The secondary name of the server (second line of MOTD).
|
||||||
* @property {string} gamemode - The default gamemode (e.g., "Survival")
|
* @property {string} gamemode - The default gamemode (e.g., "Survival").
|
||||||
* @property {boolean | undefined} nintendoLimited - Whether the server is Nintendo limited
|
* @property {boolean} [nintendoLimited] - Whether the server is Nintendo limited.
|
||||||
* @property {string | undefined} port - The server's IPv4 port, if provided
|
* @property {number} [port] - The server's IPv4 port, if provided.
|
||||||
* @property {string | undefined} ipv6Port - The server's IPv6 port, if provided
|
* @property {number} [ipv6Port] - The server's IPv6 port, if provided.
|
||||||
* @property {string | undefined} editorMode - Whether the server is in editor mode, if provided. See: https://learn.microsoft.com/en-us/minecraft/creator/documents/bedrockeditor/editoroverview?view=minecraft-bedrock-stable
|
* @property {boolean} [editorMode] - Whether the server is in editor mode, if provided. See [Minecraft Editor Mode Documentation](https://learn.microsoft.com/en-us/minecraft/creator/documents/bedrockeditor/editoroverview?view=minecraft-bedrock-stable) for more details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the structured and user-friendly response from a server ping.
|
* Represents the structured and user-friendly response from a server ping.
|
||||||
* This is the public-facing object that users of the library will receive.
|
* This is the public-facing object that users of the library will receive.
|
||||||
* @typedef {object} BedrockPingResponse
|
* @typedef {object} BedrockPingResponse
|
||||||
* @property {string} edition
|
* @property {string} edition - The edition of the server (MCPE or MCEE).
|
||||||
* @property {string} name
|
* @property {string} name - The primary name of the server (first line of MOTD).
|
||||||
* @property {string} levelName
|
* @property {string} levelName - The name of the world or level being hosted.
|
||||||
* @property {string} gamemode
|
* @property {string} gamemode - The default gamemode of the server.
|
||||||
* @property {{ protocol: number, minecraft: string }} version
|
* @property {{ protocol: number, minecraft: string }} version - Game and protocol versions.
|
||||||
* @property {{ online: number, max: number }} players
|
* @property {{ online: number, max: number }} players - Current and maximum player counts.
|
||||||
* @property {{ v4: number | undefined, v6: number | undefined }} port
|
* @property {{ v4?: number, v6?: number }} port - Announced IPv4 and IPv6 ports.
|
||||||
* @property {bigint} guid
|
* @property {bigint} guid - The server's unique 64-bit GUID.
|
||||||
* @property {boolean | undefined} isNintendoLimited
|
* @property {boolean} [isNintendoLimited] - True if the server restricts Nintendo Switch players.
|
||||||
* @property {string | undefined} isEditorModeEnabled
|
* @property {boolean} [isEditorModeEnabled] - True if the server is in editor mode. See [Minecraft Editor Mode Documentation](https://learn.microsoft.com/en-us/minecraft/creator/documents/bedrockeditor/editoroverview?view=minecraft-bedrock-stable) for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} BedrockPingOptions
|
||||||
|
* @property {number} [port=19132] - The server port to ping.
|
||||||
|
* @property {number} [timeout=5000] - The timeout in milliseconds for the request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Unconnected Ping packet.
|
* Creates an Unconnected Ping packet.
|
||||||
* @param {number} timestamp - The current time delta since the script started
|
* See [Unconnected Ping Documentation](https://minecraft.wiki/w/RakNet#Unconnected_Ping) for more details.
|
||||||
|
* @param {number} timestamp - The current time delta since the script started.
|
||||||
* @returns {Buffer}
|
* @returns {Buffer}
|
||||||
* @see {@link https://minecraft.wiki/w/RakNet#Unconnected_Ping}
|
|
||||||
*/
|
*/
|
||||||
const createUnconnectedPingFrame = (timestamp) => {
|
const createUnconnectedPingFrame = (timestamp) => {
|
||||||
const buffer = Buffer.alloc(33);
|
const buffer = Buffer.alloc(33);
|
||||||
@ -65,8 +74,9 @@ const createUnconnectedPingFrame = (timestamp) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the semicolon-delimited MOTD string into a structured object.
|
* Parses the semicolon-delimited MOTD string into a structured object.
|
||||||
* @param {string} motdString - The raw MOTD string from the server
|
* @param {string} motdString - The raw MOTD string from the server.
|
||||||
* @throws {Error} If the MOTD string is missing required fields
|
* @returns {BedrockMotd} The parsed internal MOTD object.
|
||||||
|
* @throws {Error} If the MOTD string is missing required fields.
|
||||||
*/
|
*/
|
||||||
const parseMotd = (motdString) => {
|
const parseMotd = (motdString) => {
|
||||||
const parts = motdString.split(";");
|
const parts = motdString.split(";");
|
||||||
@ -119,8 +129,8 @@ const parseMotd = (motdString) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms the raw MOTD object into a user-friendly, nested structure.
|
* Transforms the raw MOTD object into a user-friendly, nested structure.
|
||||||
* @param {BedrockMotd} motd - The parsed MOTD object
|
* @param {BedrockMotd} motd - The parsed MOTD object.
|
||||||
* @returns {BedrockPingResponse}
|
* @returns {BedrockPingResponse} The final, user-facing response object.
|
||||||
*/
|
*/
|
||||||
const transformMotd = (motd) => {
|
const transformMotd = (motd) => {
|
||||||
return {
|
return {
|
||||||
@ -148,9 +158,9 @@ const transformMotd = (motd) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the MOTD string from an Unconnected Pong packet and parses it.
|
* Extracts the MOTD string from an Unconnected Pong packet and parses it.
|
||||||
* @param {Buffer} pongPacket - The raw pong packet from the server
|
* @param {Buffer} pongPacket - The raw pong packet from the server.
|
||||||
* @returns {BedrockPingResponse}
|
* @returns {BedrockPingResponse} The final response object.
|
||||||
* @throws {Error} If the packet is malformed
|
* @throws {Error} If the packet is malformed.
|
||||||
*/
|
*/
|
||||||
const parseUnconnectedPong = (pongPacket) => {
|
const parseUnconnectedPong = (pongPacket) => {
|
||||||
if (!Buffer.isBuffer(pongPacket) || pongPacket.length < 35) {
|
if (!Buffer.isBuffer(pongPacket) || pongPacket.length < 35) {
|
||||||
@ -177,6 +187,7 @@ const parseUnconnectedPong = (pongPacket) => {
|
|||||||
motdOffset,
|
motdOffset,
|
||||||
motdOffset + motdLength
|
motdOffset + motdLength
|
||||||
);
|
);
|
||||||
|
debug("received raw MOTD string: %s", motdString);
|
||||||
|
|
||||||
const rawMotd = parseMotd(motdString);
|
const rawMotd = parseMotd(motdString);
|
||||||
const motd = transformMotd(rawMotd);
|
const motd = transformMotd(rawMotd);
|
||||||
@ -185,11 +196,9 @@ const parseUnconnectedPong = (pongPacket) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously pings a Minecraft Bedrock server.
|
* Asynchronously pings a Minecraft Bedrock server.
|
||||||
* @param {string} host - The IP address or hostname of the server
|
* @param {string} host - The IP address or hostname of the server.
|
||||||
* @param {object} [options] - Optional configuration
|
* @param {BedrockPingOptions} [options={}] - Optional configuration.
|
||||||
* @param {number} [options.port=19132] - The server port
|
* @returns {Promise<BedrockPingResponse>} A promise that resolves with the server's parsed MOTD.
|
||||||
* @param {number} [options.timeout=5000] - The request timeout in milliseconds
|
|
||||||
* @returns {Promise<BedrockPingResponse>} A promise that resolves with the server's parsed MOTD
|
|
||||||
*/
|
*/
|
||||||
export const pingBedrock = (host, options = {}) => {
|
export const pingBedrock = (host, options = {}) => {
|
||||||
if (!host) {
|
if (!host) {
|
||||||
@ -197,6 +206,7 @@ export const pingBedrock = (host, options = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { port = 19132, timeout = 5000 } = options;
|
const { port = 19132, timeout = 5000 } = options;
|
||||||
|
debug("pinging Bedrock server %s:%d with %dms timeout", host, port, timeout);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = dgram.createSocket("udp4");
|
const socket = dgram.createSocket("udp4");
|
||||||
@ -215,17 +225,20 @@ export const pingBedrock = (host, options = {}) => {
|
|||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (isCleanupCompleted) return;
|
if (isCleanupCompleted) return;
|
||||||
isCleanupCompleted = true;
|
isCleanupCompleted = true;
|
||||||
|
debug("cleaning up resources for %s:%d", host, port);
|
||||||
clearTimeout(timeoutTask);
|
clearTimeout(timeoutTask);
|
||||||
socket.close();
|
socket.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generic error handler
|
// Generic error handler
|
||||||
socket.on("error", (err) => {
|
socket.on("error", (err) => {
|
||||||
|
debug("socket error for %s:%d - %s", host, port, err.message);
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("message", (pongPacket) => {
|
socket.on("message", (pongPacket) => {
|
||||||
|
debug("received %d bytes from %s:%d", pongPacket.length, host, port);
|
||||||
try {
|
try {
|
||||||
const motd = parseUnconnectedPong(pongPacket);
|
const motd = parseUnconnectedPong(pongPacket);
|
||||||
cleanup();
|
cleanup();
|
||||||
@ -237,6 +250,8 @@ export const pingBedrock = (host, options = {}) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const pingPacket = createUnconnectedPingFrame(Date.now() - START_TIME);
|
const pingPacket = createUnconnectedPingFrame(Date.now() - START_TIME);
|
||||||
|
debug("sending Unconnected Ping packet to %s:%d", host, port);
|
||||||
|
debug("packet: %o", pingPacket);
|
||||||
socket.send(pingPacket, 0, pingPacket.length, port, host);
|
socket.send(pingPacket, 0, pingPacket.length, port, host);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Handle any immediate, synchronous errors that might occur when sending the ping packet
|
// Handle any immediate, synchronous errors that might occur when sending the ping packet
|
||||||
|
350
lib/java.js
350
lib/java.js
@ -1,160 +1,262 @@
|
|||||||
/**
|
/**
|
||||||
* 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 createDebug from "debug";
|
||||||
|
import * as varint from "./varint.js";
|
||||||
|
|
||||||
|
const debug = createDebug("mineping:java");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 protocol documentation.
|
||||||
* @param {string} virtualHost The host sent in handshake.
|
* See [Status Response Documentation](https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Status_Response) for more details.
|
||||||
* @param {number} [port=25565] The port of the Java server.
|
* @typedef {object} JavaPingResponse
|
||||||
* @param {function} cb The callback function to handle the ping response.
|
* @property {{ name: string, protocol: number }} version - Contains the server's version name and protocol number.
|
||||||
* @param {number} [timeout=5000] The timeout duration in milliseconds.
|
* @property {{ max: number, online: number, sample?: Array<{ name: string, id: string }> }} [players] - Player count and a sample of online players.
|
||||||
* @param {number} [protocolVersion=-1] The protocol version of the Java client.
|
* @property {object | string} [description] - The server's Message of the Day (MOTD).
|
||||||
|
* @property {string} [favicon] - A Base64-encoded 64x64 PNG image data URI.
|
||||||
|
* @property {boolean} [enforcesSecureChat] - True if the server requires clients to have a Mojang-signed public key.
|
||||||
|
* @property {boolean} [preventsChatReports] - True if a mod is installed to disable chat reporting.
|
||||||
*/
|
*/
|
||||||
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
|
* @typedef {object} JavaPingOptions
|
||||||
|
* @property {number} [port=25565] - The fallback port if an SRV record is not found.
|
||||||
|
* @property {number} [timeout=5000] - The connection timeout in milliseconds.
|
||||||
|
* @property {number} [protocolVersion=-1] - The protocol version to use in the handshake. `-1` is for auto-detection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
debug("packet incomplete, waiting for more data");
|
||||||
|
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) {
|
||||||
|
debug("JSON string incomplete, waiting for more data");
|
||||||
|
return null; // Incomplete JSON string, wait for more data.
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonString = buffer
|
||||||
|
.subarray(offset, offset + jsonLength)
|
||||||
|
.toString("utf8");
|
||||||
|
debug("received raw JSON response");
|
||||||
|
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 instanceof varint.VarIntError) {
|
||||||
|
if (err.code === varint.ERR_VARINT_BUFFER_UNDERFLOW) {
|
||||||
|
debug("buffer underflow while parsing VarInt, waiting for more data");
|
||||||
|
return null; // Wait for more data.
|
||||||
|
}
|
||||||
|
// For malformed VarInts or JSON, throw the error to reject the promise.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously 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 {JavaPingOptions} [options={}] - Optional configuration.
|
||||||
|
* @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;
|
||||||
|
debug("pinging Java server %s with options: %o", host, options);
|
||||||
|
|
||||||
|
let targetHost = host;
|
||||||
|
let targetPort = fallbackPort;
|
||||||
|
|
||||||
|
try {
|
||||||
|
debug("attempting SRV lookup for _minecraft._tcp.%s", host);
|
||||||
|
const srvRecords = await dns.resolveSrv(`_minecraft._tcp.${host}`);
|
||||||
|
if (srvRecords.length > 0) {
|
||||||
|
targetHost = srvRecords[0].name;
|
||||||
|
targetPort = srvRecords[0].port;
|
||||||
|
debug("SRV lookup successful, new target: %s:%d", targetHost, targetPort);
|
||||||
|
}
|
||||||
|
} 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 (
|
||||||
|
err instanceof Error &&
|
||||||
|
"code" in err &&
|
||||||
|
(err.code === "ENODATA" || err.code === "ENOTFOUND")
|
||||||
|
) {
|
||||||
|
// Non fatal DNS error, log it and continue
|
||||||
|
debug("SRV lookup for %s failed (%s), using fallback.", host, err.code);
|
||||||
|
} else {
|
||||||
|
// Re-throw anything else to fail the operation
|
||||||
|
debug("SRV lookup for %s failed unexpectedly, re-throwing.", host, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
debug("creating TCP connection to %s:%d", targetHost, targetPort);
|
||||||
|
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;
|
||||||
|
debug("cleaning up resources for %s:%d", targetHost, targetPort);
|
||||||
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) => {
|
||||||
|
debug("socket error for %s:%d - %s", targetHost, targetPort, err.message);
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("close", () => {
|
||||||
|
if (!isCleanupCompleted) {
|
||||||
|
debug("socket for %s:%d closed prematurely", targetHost, targetPort);
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Socket closed unexpectedly without a response."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
const handshake = varint.concat([
|
debug(
|
||||||
varint.encodeInt(0),
|
"socket connected to %s:%d, sending packets...",
|
||||||
varint.encodeInt(protocolVersion),
|
targetHost,
|
||||||
varint.encodeInt(virtualHost.length),
|
targetPort
|
||||||
varint.encodeString(virtualHost),
|
);
|
||||||
varint.encodeUShort(port),
|
try {
|
||||||
varint.encodeInt(1),
|
const handshakePacket = createHandshakePacket(
|
||||||
]);
|
host,
|
||||||
|
targetPort,
|
||||||
socket.write(handshake);
|
protocolVersion
|
||||||
|
);
|
||||||
const request = varint.concat([varint.encodeInt(0)]);
|
const statusRequestPacket = createStatusRequestPacket();
|
||||||
|
socket.write(handshakePacket);
|
||||||
socket.write(request);
|
socket.write(statusRequestPacket);
|
||||||
|
} catch (err) {
|
||||||
|
// Handle synchronous errors during packet creation/writing
|
||||||
|
socket.emit("error", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let incomingBuffer = Buffer.alloc(0);
|
let incomingBuffer = Buffer.alloc(0);
|
||||||
|
|
||||||
socket.on("data", (data) => {
|
socket.on("data", (data) => {
|
||||||
|
debug(
|
||||||
|
"received %d bytes of data, total buffer size is now %d bytes",
|
||||||
|
data.length,
|
||||||
|
incomingBuffer.length + data.length
|
||||||
|
);
|
||||||
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();
|
debug("successfully parsed full response");
|
||||||
cb(message, null);
|
// 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) {
|
} 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
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
183
lib/varint.js
183
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";
|
||||||
|
|
||||||
|
export class VarIntError extends Error {
|
||||||
|
/**
|
||||||
|
* @param {string} message The error message.
|
||||||
|
* @param {string} code The error code.
|
||||||
|
*/
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message);
|
||||||
|
this.name = "VarIntError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility object for encoding and decoding varints.
|
* Encodes an integer into a VarInt buffer.
|
||||||
|
* VarInts are never longer than 5 bytes for the Minecraft protocol.
|
||||||
|
* @param {number} value The integer to encode
|
||||||
|
* @returns {Buffer} The encoded VarInt as a buffer
|
||||||
|
* @throws {VarIntError} if the value is too large to be encoded
|
||||||
*/
|
*/
|
||||||
const varint = {
|
export function encodeVarInt(value) {
|
||||||
/**
|
|
||||||
* Encodes an integer value into a varint byte buffer.
|
|
||||||
* @param {number} val - The integer value to encode.
|
|
||||||
* @returns {Buffer}
|
|
||||||
*/
|
|
||||||
encodeInt: (val) => {
|
|
||||||
// "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 new VarIntError(
|
||||||
|
"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.
|
* Encodes a string into a UTF-8 buffer.
|
||||||
* @param {string} val - The string value to encode.
|
* @param {string} value The string to encode
|
||||||
* @returns {Buffer}
|
* @returns {Buffer}
|
||||||
*/
|
*/
|
||||||
encodeString: (val) => {
|
export function encodeString(value) {
|
||||||
return Buffer.from(val, "utf-8");
|
return Buffer.from(value, "utf-8");
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes an unsigned short value into a byte buffer.
|
* Encodes an unsigned short (16-bit big-endian) into a 2-byte buffer.
|
||||||
* @param {number} val - The unsigned short value to encode.
|
* @param {number} value The number to encode
|
||||||
* @returns {Buffer}
|
* @returns {Buffer}
|
||||||
*/
|
*/
|
||||||
encodeUShort: (val) => {
|
export function encodeUShort(value) {
|
||||||
return Buffer.from([val >> 8, val & 0xff]);
|
const buf = Buffer.alloc(2);
|
||||||
},
|
buf.writeUInt16BE(value, 0);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Concatenates multiple byte buffers into a single byte buffer.
|
* Creates a Minecraft-style packet by concatenating chunks and prefixing the total length as a VarInt.
|
||||||
* @param {Buffer[]} chunks - An array of byte buffers to concatenate.
|
* @param {Buffer[]} chunks An array of buffers to include in the packet payload
|
||||||
* @returns {Buffer}
|
* @returns {Buffer} The complete packet with its length prefix
|
||||||
*/
|
*/
|
||||||
concat: (chunks) => {
|
export function concatPackets(chunks) {
|
||||||
let length = 0;
|
const payload = Buffer.concat(chunks);
|
||||||
|
const lengthPrefix = encodeVarInt(payload.length);
|
||||||
|
return Buffer.concat([lengthPrefix, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
/**
|
||||||
length += chunk.length;
|
* 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 {VarIntError} if the buffer is too short or the VarInt is malformed
|
||||||
|
*/
|
||||||
|
export function decodeVarInt(buffer, offset = 0) {
|
||||||
|
if (offset >= buffer.length) {
|
||||||
|
throw new VarIntError(
|
||||||
|
"Buffer underflow: Cannot decode VarInt at or beyond buffer length.",
|
||||||
|
ERR_VARINT_BUFFER_UNDERFLOW
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = [varint.encodeInt(length), ...chunks];
|
// Fast path for single-byte VarInts, which are very common.
|
||||||
|
|
||||||
return Buffer.concat(buffer);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes a varint integer value from a buffer.
|
|
||||||
* @param {Buffer} buffer - The byte buffer to decode from.
|
|
||||||
* @param {number} offset - The offset in the buffer to start decoding from.
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
decodeInt: (buffer, offset) => {
|
|
||||||
// Fast path for single-byte varints
|
|
||||||
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 new VarIntError(
|
||||||
|
"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 new VarIntError(
|
||||||
}
|
"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;
|
|
||||||
|
60
package-lock.json
generated
60
package-lock.json
generated
@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "@minescope/mineping",
|
"name": "@minescope/mineping",
|
||||||
"version": "1.6.1",
|
"version": "1.7.0-beta.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@minescope/mineping",
|
"name": "@minescope/mineping",
|
||||||
"version": "1.6.1",
|
"version": "1.7.0-beta.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^24.0.3",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
"vitest": "^3.2.3"
|
"vitest": "^3.2.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -737,6 +743,16 @@
|
|||||||
"@types/deep-eql": "*"
|
"@types/deep-eql": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
|
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@ -751,6 +767,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
|
||||||
|
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz",
|
||||||
@ -917,7 +950,6 @@
|
|||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -1067,7 +1099,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -1307,6 +1338,27 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||||
|
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"version": "6.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||||
|
11
package.json
11
package.json
@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@minescope/mineping",
|
"name": "@minescope/mineping",
|
||||||
"version": "1.7.0-beta.0",
|
"version": "1.7.0-beta.1",
|
||||||
"description": "Ping both Minecraft Bedrock and Java servers.",
|
"description": "Ping both Minecraft Bedrock and Java servers.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "types/index.d.ts",
|
"types": "types/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@ -23,7 +24,13 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^24.0.3",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
"vitest": "^3.2.3"
|
"vitest": "^3.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["lib", "index.js"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Reference in New Issue
Block a user