3 Commits

Author SHA1 Message Date
a1b999ca4e refactor: introduce typescript for static type checking
We use JSDoc for documentation, but these annotations
were not being validated. This meant that type information
could become outdated or incorrect without any warning.

This commit introduces the TypeScript compiler (`tsc`) as a static
analysis tool to leverage our existing JSDoc comments.

To support this, JSDoc annotations across the codebase have been
improved for accuracy. Additionally, the `varint` module now uses a
custom `VarIntError` class for better type inference and error handling.
A new `typecheck` script has been added to `package.json` to run this
validation.
2025-06-19 03:55:24 +03:00
7322034aba feat: add debug logging
Add debug logs using the `debug` library.

To use this new feature, set the `DEBUG` environment variable:
- `DEBUG=mineping:*` enables all logs from this library.
- `DEBUG=mineping:java` enables logs for the Java module only.
- `DEBUG=mineping:bedrock` enables logs for the Bedrock module only.
2025-06-19 02:14:04 +03:00
c7b99cb6db 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.
2025-06-19 01:45:27 +03:00
8 changed files with 564 additions and 353 deletions

View File

@ -7,6 +7,9 @@
import dgram from "node:dgram";
import crypto from "node:crypto";
import createDebug from "debug";
const debug = createDebug("mineping:bedrock");
const MAGIC = "00ffff00fefefefefdfdfdfd12345678";
const START_TIME = Date.now();
@ -15,44 +18,50 @@ const UNCONNECTED_PONG = 0x1c;
/**
* Representation of raw, semicolon-delimited MOTD string.
* 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
* @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 {number} protocol - The protocol version
* @property {string} version - The game version (e.g., "1.21.2")
* @property {number} playerCount - The current number of players online
* @property {number} playerMax - The maximum number of players allowed
* @property {bigint} serverGuid - The server's GUID
* @property {string} subName - The secondary name of the server (second line of MOTD)
* @property {string} gamemode - The default gamemode (e.g., "Survival")
* @property {boolean | undefined} nintendoLimited - Whether the server is Nintendo limited
* @property {string | undefined} port - The server's IPv4 port, if provided
* @property {string | undefined} 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 {string} edition - The edition of the server (MCPE or MCEE).
* @property {string} name - The primary name of the server (first line of MOTD).
* @property {number} protocol - The protocol version.
* @property {string} version - The game version (e.g., "1.21.2").
* @property {number} playerCount - The current number of players online.
* @property {number} playerMax - The maximum number of players allowed.
* @property {bigint} serverGuid - The server's GUID.
* @property {string} subName - The secondary name of the server (second line of MOTD).
* @property {string} gamemode - The default gamemode (e.g., "Survival").
* @property {boolean} [nintendoLimited] - Whether the server is Nintendo limited.
* @property {number} [port] - The server's IPv4 port, if provided.
* @property {number} [ipv6Port] - The server's IPv6 port, if provided.
* @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.
* This is the public-facing object that users of the library will receive.
* @typedef {object} BedrockPingResponse
* @property {string} edition
* @property {string} name
* @property {string} levelName
* @property {string} gamemode
* @property {{ protocol: number, minecraft: string }} version
* @property {{ online: number, max: number }} players
* @property {{ v4: number | undefined, v6: number | undefined }} port
* @property {bigint} guid
* @property {boolean | undefined} isNintendoLimited
* @property {string | undefined} isEditorModeEnabled
* @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} levelName - The name of the world or level being hosted.
* @property {string} gamemode - The default gamemode of the server.
* @property {{ protocol: number, minecraft: string }} version - Game and protocol versions.
* @property {{ online: number, max: number }} players - Current and maximum player counts.
* @property {{ v4?: number, v6?: number }} port - Announced IPv4 and IPv6 ports.
* @property {bigint} guid - The server's unique 64-bit GUID.
* @property {boolean} [isNintendoLimited] - True if the server restricts Nintendo Switch players.
* @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.
* @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}
* @see {@link https://minecraft.wiki/w/RakNet#Unconnected_Ping}
*/
const createUnconnectedPingFrame = (timestamp) => {
const buffer = Buffer.alloc(33);
@ -65,8 +74,9 @@ const createUnconnectedPingFrame = (timestamp) => {
/**
* Parses the semicolon-delimited MOTD string into a structured object.
* @param {string} motdString - The raw MOTD string from the server
* @throws {Error} If the MOTD string is missing required fields
* @param {string} motdString - The raw MOTD string from the server.
* @returns {BedrockMotd} The parsed internal MOTD object.
* @throws {Error} If the MOTD string is missing required fields.
*/
const parseMotd = (motdString) => {
const parts = motdString.split(";");
@ -119,8 +129,8 @@ const parseMotd = (motdString) => {
/**
* Transforms the raw MOTD object into a user-friendly, nested structure.
* @param {BedrockMotd} motd - The parsed MOTD object
* @returns {BedrockPingResponse}
* @param {BedrockMotd} motd - The parsed MOTD object.
* @returns {BedrockPingResponse} The final, user-facing response object.
*/
const transformMotd = (motd) => {
return {
@ -148,9 +158,9 @@ const transformMotd = (motd) => {
/**
* Extracts the MOTD string from an Unconnected Pong packet and parses it.
* @param {Buffer} pongPacket - The raw pong packet from the server
* @returns {BedrockPingResponse}
* @throws {Error} If the packet is malformed
* @param {Buffer} pongPacket - The raw pong packet from the server.
* @returns {BedrockPingResponse} The final response object.
* @throws {Error} If the packet is malformed.
*/
const parseUnconnectedPong = (pongPacket) => {
if (!Buffer.isBuffer(pongPacket) || pongPacket.length < 35) {
@ -177,6 +187,7 @@ const parseUnconnectedPong = (pongPacket) => {
motdOffset,
motdOffset + motdLength
);
debug("received raw MOTD string: %s", motdString);
const rawMotd = parseMotd(motdString);
const motd = transformMotd(rawMotd);
@ -185,11 +196,9 @@ const parseUnconnectedPong = (pongPacket) => {
/**
* Asynchronously pings a Minecraft Bedrock server.
* @param {string} host - The IP address or hostname of the server
* @param {object} [options] - Optional configuration
* @param {number} [options.port=19132] - The server port
* @param {number} [options.timeout=5000] - The request timeout in milliseconds
* @returns {Promise<BedrockPingResponse>} A promise that resolves with the server's parsed MOTD
* @param {string} host - The IP address or hostname of the server.
* @param {BedrockPingOptions} [options={}] - Optional configuration.
* @returns {Promise<BedrockPingResponse>} A promise that resolves with the server's parsed MOTD.
*/
export const pingBedrock = (host, options = {}) => {
if (!host) {
@ -197,6 +206,7 @@ export const pingBedrock = (host, options = {}) => {
}
const { port = 19132, timeout = 5000 } = options;
debug("pinging Bedrock server %s:%d with %dms timeout", host, port, timeout);
return new Promise((resolve, reject) => {
const socket = dgram.createSocket("udp4");
@ -215,17 +225,20 @@ export const pingBedrock = (host, options = {}) => {
const cleanup = () => {
if (isCleanupCompleted) return;
isCleanupCompleted = true;
debug("cleaning up resources for %s:%d", host, port);
clearTimeout(timeoutTask);
socket.close();
};
// Generic error handler
socket.on("error", (err) => {
debug("socket error for %s:%d - %s", host, port, err.message);
cleanup();
reject(err);
});
socket.on("message", (pongPacket) => {
debug("received %d bytes from %s:%d", pongPacket.length, host, port);
try {
const motd = parseUnconnectedPong(pongPacket);
cleanup();
@ -237,6 +250,8 @@ export const pingBedrock = (host, options = {}) => {
try {
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);
} catch (err) {
// Handle any immediate, synchronous errors that might occur when sending the ping packet

View File

@ -1,160 +1,262 @@
/**
* Implementation of the Java Minecraft ping protocol.
* @see https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Server_List_Ping
* @see https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping
*/
"use strict";
import net from "node:net";
import varint from "./varint.js";
import dns from "node:dns/promises";
import createDebug from "debug";
import * as varint from "./varint.js";
const debug = createDebug("mineping:java");
/**
* Ping a Minecraft Java server.
* @param {string} host The host of the Java server.
* @param {string} virtualHost The host sent in handshake.
* @param {number} [port=25565] The port of the Java server.
* @param {function} cb The callback function to handle the ping response.
* @param {number} [timeout=5000] The timeout duration in milliseconds.
* @param {number} [protocolVersion=-1] The protocol version of the Java client.
* Represents the structured and user-friendly response from a server ping.
* The fields and their optionality are based on the protocol documentation.
* See [Status Response Documentation](https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Status_Response) for more details.
* @typedef {object} JavaPingResponse
* @property {{ name: string, protocol: number }} version - Contains the server's version name and protocol number.
* @property {{ max: number, online: number, sample?: Array<{ name: string, id: string }> }} [players] - Player count and a sample of online players.
* @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
const timeoutTask = setTimeout(() => {
socket.emit("error", new Error("Socket timeout"));
}, timeout);
/**
* @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.
*/
const closeSocket = () => {
socket.destroy();
clearTimeout(timeoutTask);
};
/**
* 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);
// 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;
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
];
/**
* Handle any error that occurs during the ping process.
* @param {Error} err The error that occurred.
*/
const handleError = (err) => {
if (!didFireError) {
didFireError = true;
closeSocket();
cb(null, err);
}
};
// #setNoDelay instantly flushes data during read/writes
// This prevents the runtime from delaying the write at all
socket.setNoDelay(true);
socket.on("connect", () => {
const handshake = varint.concat([
varint.encodeInt(0),
varint.encodeInt(protocolVersion),
varint.encodeInt(virtualHost.length),
varint.encodeString(virtualHost),
varint.encodeUShort(port),
varint.encodeInt(1),
]);
socket.write(handshake);
const request = varint.concat([varint.encodeInt(0)]);
socket.write(request);
});
let incomingBuffer = Buffer.alloc(0);
socket.on("data", (data) => {
incomingBuffer = Buffer.concat([incomingBuffer, data]);
// Wait until incomingBuffer is at least 5 bytes long to ensure it has captured the first VarInt value
// This value is used to determine the full read length of the response
// "VarInts are never longer than 5 bytes"
// https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Data_types#VarInt_and_VarLong
if (incomingBuffer.length < 5) {
return;
}
let offset = 0;
const packetLength = varint.decodeInt(incomingBuffer, offset);
// Ensure incomingBuffer contains the full response
if (incomingBuffer.length - offset < packetLength) {
return;
}
const packetId = varint.decodeInt(
incomingBuffer,
varint.decodeLength(packetLength)
);
if (packetId === 0) {
const data = incomingBuffer.subarray(
varint.decodeLength(packetLength) + varint.decodeLength(packetId)
);
const responseLength = varint.decodeInt(data, 0);
const response = data.subarray(
varint.decodeLength(responseLength),
varint.decodeLength(responseLength) + responseLength
);
try {
const message = JSON.parse(response);
closeSocket();
cb(message, null);
} catch (err) {
handleError(err);
}
} else {
handleError(new Error("Received unexpected packet"));
}
});
socket.on("error", handleError);
return varint.concatPackets(payload);
}
/**
* Asynchronously ping Minecraft Java server.
* The optional `options` argument can be an object with a `port` (default is `25565`) or/and `timeout` (default is `5000`) or/and `protocolVersion` (default is `-1`) property.
* @param {string} host The Java server address.
* @param {import('../types/index.js').PingOptions} options The configuration for pinging Minecraft Java server.
* @returns {Promise<import('../types/index.js').JavaPingResponse>}
* Creates the Status Request packet.
* @returns {Buffer} The complete Status Request packet
*/
export function pingJava(host, options = {}) {
if (!host) throw new Error("Host argument is not provided");
function createStatusRequestPacket() {
const payload = [
varint.encodeVarInt(0x00), // Packet ID
];
return varint.concatPackets(payload);
}
/**
* Attempts to parse the server status response from the buffer.
* @param {Buffer} buffer The incoming data buffer
* @returns {{ response: JavaPingResponse, remainder: Buffer } | null} The parsed response and the remaining buffer, or null if the packet is incomplete
*/
function processResponse(buffer) {
let offset = 0;
try {
const packetLengthResult = varint.decodeVarInt(buffer, offset);
const packetLength = packetLengthResult.value;
offset += packetLengthResult.bytesRead;
// Check if the full packet has arrived yet.
if (buffer.length < offset + packetLength) {
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 = 25565,
port: fallbackPort = 25565,
timeout = 5000,
protocolVersion = -1,
virtualHost = null,
} = 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) => {
ping(
host,
virtualHost || host,
port,
(res, err) => {
err ? reject(err) : resolve(res);
},
timeout,
protocolVersion
);
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(() => {
socket.emit("error", new Error("Socket timeout"));
}, timeout);
// Idempotent function to handle cleanup tasks, we can safely call it multiple times without side effects
const cleanup = () => {
if (isCleanupCompleted) return;
isCleanupCompleted = true;
debug("cleaning up resources for %s:%d", targetHost, targetPort);
clearTimeout(timeoutTask);
socket.destroy();
};
// #setNoDelay instantly flushes data during read/writes
// This prevents the runtime from delaying the write at all
socket.setNoDelay(true);
// Generic error handler
socket.on("error", (err) => {
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", () => {
debug(
"socket connected to %s:%d, sending packets...",
targetHost,
targetPort
);
try {
const handshakePacket = createHandshakePacket(
host,
targetPort,
protocolVersion
);
const statusRequestPacket = createStatusRequestPacket();
socket.write(handshakePacket);
socket.write(statusRequestPacket);
} catch (err) {
// Handle synchronous errors during packet creation/writing
socket.emit("error", err);
}
});
let incomingBuffer = Buffer.alloc(0);
socket.on("data", (data) => {
debug(
"received %d bytes of data, total buffer size is now %d bytes",
data.length,
incomingBuffer.length + data.length
);
incomingBuffer = Buffer.concat([incomingBuffer, data]);
try {
const result = processResponse(incomingBuffer);
if (result) {
debug("successfully parsed full response");
// We successfully parsed a response. Clean up before resolving.
cleanup();
resolve(result.response);
}
// If result is null, we just wait for more data to arrive.
} catch (err) {
socket.emit("error", err);
}
});
});
}

View File

@ -1,123 +1,138 @@
// 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 = {
/**
* 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);
let written = 0;
export function encodeVarInt(value) {
const buf = Buffer.alloc(5);
let written = 0;
let val = value;
while (true) {
const byte = val & 0x7f;
val >>>= 7;
while (true) {
const byte = val & 0x7f;
val >>>= 7;
if (val === 0) {
buf.writeUInt8(byte, written++);
break;
}
buf.writeUInt8(byte | 0x80, written++);
if (val === 0) {
buf.writeUInt8(byte, written++);
break;
}
return buf.subarray(0, written);
},
buf.writeUInt8(byte | 0x80, 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");
},
if (written >= 5 && val > 0) {
throw new VarIntError(
"Value too large for a 5-byte VarInt",
ERR_VARINT_ENCODE_TOO_LARGE
);
}
}
/**
* 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]);
},
return buf.subarray(0, written);
}
/**
* 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;
/**
* Encodes a string into a UTF-8 buffer.
* @param {string} value The string to encode
* @returns {Buffer}
*/
export function encodeString(value) {
return Buffer.from(value, "utf-8");
}
for (const chunk of chunks) {
length += chunk.length;
/**
* Encodes an unsigned short (16-bit big-endian) into a 2-byte buffer.
* @param {number} value The number to encode
* @returns {Buffer}
*/
export function encodeUShort(value) {
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 {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
);
}
// Fast path for single-byte VarInts, which are very common.
const firstByte = buffer.readUInt8(offset);
if ((firstByte & 0x80) === 0) {
return { value: firstByte, bytesRead: 1 };
}
let val = firstByte & 0x7f; // Get the first 7 bits
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 buffer = [varint.encodeInt(length), ...chunks];
const byte = buffer.readUInt8(currentOffset);
bytesRead++;
currentOffset++;
return Buffer.concat(buffer);
},
val |= (byte & 0x7f) << position;
position += 7;
/**
* 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);
if (firstByte < 0x80) {
return firstByte;
if ((byte & 0x80) === 0) {
return { value: val, bytesRead: bytesRead };
}
}
let val = firstByte & 0x7f;
let position = 7;
while (position < 32) {
const byte = buffer.readUInt8(++offset);
val |= (byte & 0x7f) << position;
if ((byte & 0x80) === 0) {
return val;
}
position += 7;
}
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;
throw new VarIntError(
"VarInt is too big or malformed: 5 bytes read with continuation bit still set.",
ERR_VARINT_MALFORMED
);
}

60
package-lock.json generated
View File

@ -1,14 +1,20 @@
{
"name": "@minescope/mineping",
"version": "1.6.1",
"version": "1.7.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@minescope/mineping",
"version": "1.6.1",
"version": "1.7.0-beta.1",
"license": "MIT",
"dependencies": {
"debug": "^4.4.1"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/node": "^24.0.3",
"typescript": "^5.8.3",
"vitest": "^3.2.3"
},
"engines": {
@ -737,6 +743,16 @@
"@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": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -751,6 +767,23 @@
"dev": true,
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz",
@ -917,7 +950,6 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -1067,7 +1099,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -1307,6 +1338,27 @@
"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": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@ -1,13 +1,14 @@
{
"name": "@minescope/mineping",
"version": "1.7.0-beta.0",
"version": "1.7.0-beta.1",
"description": "Ping both Minecraft Bedrock and Java servers.",
"main": "index.js",
"type": "module",
"types": "types/index.d.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"repository": {
"type": "git",
@ -23,7 +24,13 @@
"engines": {
"node": ">=14"
},
"dependencies": {
"debug": "^4.4.1"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/node": "^24.0.3",
"typescript": "^5.8.3",
"vitest": "^3.2.3"
}
}

View File

@ -1,112 +1,133 @@
import net from "node:net";
import dns from "node:dns/promises";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
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:dns/promises");
describe("pingJava", () => {
let mockSocket;
beforeEach(() => {
// Simulate no SRV record found.
dns.resolveSrv.mockResolvedValue([]);
const mockHandlers = {};
mockSocket = {
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(),
on: vi.fn((event, handler) => (mockHandlers[event] = handler)),
emit: vi.fn((event, ...args) => mockHandlers[event]?.(...args)),
};
net.createConnection = vi.fn().mockReturnValue(mockSocket);
net.createConnection.mockReturnValue(mockSocket);
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("should ping a server and handle a chunked response", async () => {
const host = "mc.hypixel.net";
const options = {
port: 25565,
timeout: 5000,
protocolVersion: 765,
virtualHost: "mc.hypixel.net",
};
const options = { port: 25565 };
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({
host,
port: options.port,
});
expect(mockSocket.setNoDelay).toHaveBeenCalledWith(true);
mockSocket.emit("connect");
expect(mockSocket.write).toHaveBeenCalledTimes(2);
const mockResponse = {
version: { name: "1.21", protocol: 765 },
players: { max: 20, online: 5, sample: [] },
description: "A Minecraft Server",
favicon: "data:image/png;base64,iVBORw0KGgo...",
};
const fullPacket = createMockJavaResponse(mockResponse);
const chunk1 = fullPacket.subarray(0, 10);
const chunk2 = fullPacket.subarray(10);
// Simulate receiving data in chunks
mockSocket.emit("data", chunk1);
mockSocket.emit("data", chunk2);
const result = await pingPromise;
expect(result).toEqual(mockResponse);
expect(mockSocket.destroy).toHaveBeenCalled();
});
describe("errors", () => {
it("should throw an error if host is not provided", () => {
expect(() => pingJava(null)).toThrow("Host argument is not provided");
it("should throw an error if host is not provided", async () => {
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 });
await vi.runAllTicks();
mockSocket.emit("connect");
// Advance time to trigger the timeout
vi.advanceTimersByTime(1000);
await expect(pingPromise).rejects.toThrow("Socket timeout");
expect(mockSocket.destroy).toHaveBeenCalled();
});
it("should reject on connection error", async () => {
const pingPromise = pingJava("localhost");
// Simulate a connection refusal
await vi.runAllTicks();
mockSocket.emit("error", new Error("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 () => {
const pingPromise = pingJava("localhost");
// Fire two errors back-to-back
await vi.runAllTicks();
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");
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) {
const jsonString = JSON.stringify(response);
const jsonBuffer = Buffer.from(jsonString, "utf8");
const responseLength = varint.encodeInt(jsonBuffer.length);
const packetId = varint.encodeInt(0);
const packetData = Buffer.concat([packetId, responseLength, jsonBuffer]);
const packetLength = varint.encodeInt(packetData.length);
return Buffer.concat([packetLength, packetData]);
const jsonBuffer = varint.encodeString(jsonString);
const jsonLength = varint.encodeVarInt(jsonBuffer.length);
const packetId = varint.encodeVarInt(0x00);
const payloadParts = [packetId, jsonLength, jsonBuffer];
return varint.concatPackets(payloadParts);
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import varint from "../lib/varint.js";
import * as varint from "../lib/varint.js";
describe("varint.js", () => {
it("should encode and decode integers symmetrically (round-trip)", () => {
@ -16,8 +16,8 @@ describe("varint.js", () => {
];
testValues.forEach((value) => {
const encoded = varint.encodeInt(value);
const decoded = varint.decodeInt(encoded, 0);
const encoded = varint.encodeVarInt(value);
const { value: decoded } = varint.decodeVarInt(encoded, 0);
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", () => {
// [255 (invalid varint), 128 (valid varint), 127 (valid varint)]
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", () => {
const invalidBuffer = Buffer.from([0x80, 0x80, 0x80, 0x80, 0x80, 0x80]);
expect(() => varint.decodeInt(invalidBuffer, 0)).toThrow(
"VarInt is too big"
expect(() => varint.decodeVarInt(invalidBuffer, 0)).toThrow(
"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", () => {
expect(varint.encodeUShort(0)).toEqual(Buffer.from([0x00, 0x00]));
expect(varint.encodeUShort(256)).toEqual(Buffer.from([0x01, 0x00]));
expect(varint.encodeUShort(65535)).toEqual(Buffer.from([0xff, 0xff]));
});
it("should correctly assemble and parse a Minecraft handshake packet", () => {
const protocolVersion = -1;
const virtualHost = "mc.example.com";
const port = 25565;
const payload = Buffer.concat([
varint.encodeInt(0),
varint.encodeInt(protocolVersion),
varint.encodeInt(virtualHost.length),
varint.encodeString(virtualHost),
varint.encodeUShort(port),
varint.encodeInt(1),
]);
const finalPacket = varint.concat([payload]);
const decodedPacketLength = varint.decodeInt(finalPacket, 0);
it("should correctly assemble a Minecraft packet with a length prefix", () => {
const payloadParts = [
varint.encodeVarInt(0), // protocol
varint.encodeString("mc.example.com"), // host
varint.encodeUShort(25565), // port
];
const payload = Buffer.concat(payloadParts);
const finalPacket = varint.concatPackets(payloadParts);
const { value: decodedPacketLength, bytesRead } = varint.decodeVarInt(
finalPacket,
0
);
expect(decodedPacketLength).toBe(payload.length);
const lengthOfPacketLength = varint.decodeLength(decodedPacketLength);
const decodedPayload = finalPacket.subarray(lengthOfPacketLength);
const decodedPayload = finalPacket.subarray(bytesRead);
expect(decodedPayload).toEqual(payload);
});
});

14
tsconfig.json Normal file
View 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"]
}