10 Commits

Author SHA1 Message Date
51b4771305 docs: add guide how to install beta version 2025-06-16 03:39:21 +03:00
3c2c049c19 refactor!: decouple Raknet MOTD parsing and response shaping
The previous implementation of the RakNet ping was monolithic, mixing
socket management, raw packet validation, and data transformation into
a single, complex flow.

This refactor introduces a clear, multi-stage
processing pipeline that separates these concerns. The logic is now
broken down into multi-stage pipeline: extracting the MOTD string
from the raw pong packet -> parsing that string into a raw
object -> transforming the raw data into a
user-friendly response object.

Additionally, the socket handling logic is improved
with idempotent cleanup function to prevent resource
leaks or race conditions.

As part of this overhaul, external TypeScript definition (`.d.ts`)
files have been removed in favor of rich JSDoc annotations.

BREAKING CHANGE: The structure of the resolved `BedrockPingResponse`
object has been significantly changed to improve clarity and
consistency.
2025-06-16 03:36:26 +03:00
cbaa1a3e3e docs: update email in security policy 2025-06-15 03:00:19 +03:00
435e59739c test: implement tests using vitest framework 2025-06-15 02:56:07 +03:00
ef2bebe755 fix: only perform cleanup and fire the callback on the first error 2025-06-15 02:56:07 +03:00
27011d4091 fix: change minimum motd components to 5 and fix typos
A valid motd message has at least 5 components, not 9
2025-06-14 23:26:11 +03:00
d8d4a9a467 chore: bump version to 1.6.1 2025-02-07 08:57:18 +03:00
d90a916fa5 docs: update cli app example 2025-02-07 08:56:39 +03:00
0959403b1b refactor: improve Bedrock error handling and validation 2025-02-07 08:48:04 +03:00
c71236f223 perf: optimize varint decoding
docs: update type definitions
2025-02-07 08:35:21 +03:00
16 changed files with 2259 additions and 401 deletions

View File

@ -16,13 +16,16 @@ To install `mineping`, simply run the following command:
npm i @minescope/mineping
```
> To install _beta_ version (if available), run: `npm i @minescope/mineping@next`
## Loading and configuration the module
### ES Modules (ESM)
If you are using ES Modules, you can import the library like this:
```js
import { pingJava, pingBedrock } from '@minescope/mineping';
import { pingJava, pingBedrock } from "@minescope/mineping";
```
### CommonJS
@ -31,8 +34,10 @@ import { pingJava, pingBedrock } from '@minescope/mineping';
If you cannot switch to ESM, you can use the async `import()` function from CommonJS to load `mineping` asynchronously:
```js
const pingJava = (...args) => import('@minescope/mineping').then(module => module.pingJava(...args));
const pingBedrock = (...args) => import('@minescope/mineping').then(module => module.pingBedrock(...args));
const pingJava = (...args) =>
import("@minescope/mineping").then((module) => module.pingJava(...args));
const pingBedrock = (...args) =>
import("@minescope/mineping").then((module) => module.pingBedrock(...args));
```
## Usage
@ -40,20 +45,20 @@ const pingBedrock = (...args) => import('@minescope/mineping').then(module => mo
Ping a Java server with default options:
```js
import { pingJava } from '@minescope/mineping';
import { pingJava } from "@minescope/mineping";
const data = await pingJava('mc.hypixel.net');
const data = await pingJava("mc.hypixel.net");
console.log(data);
```
Ping a Bedrock server with custom options:
```js
import { pingBedrock } from '@minescope/mineping';
import { pingBedrock } from "@minescope/mineping";
const data = await pingBedrock('mco.mineplex.com', {
port: 19132,
timeout: 500
const data = await pingBedrock("mco.mineplex.com", {
port: 19132,
timeout: 500,
});
console.log(data);
```

View File

@ -1,5 +1,5 @@
# Reporting a Vulnerability
If you believe you have found a security vulnerability, let us know by sending email to contact@zeldon.ru We will investigate that and do our best to quickly fix the problem.
If you believe you have found a security vulnerability, let me know by sending email to timofey@z4n.me I will investigate that and do my best to quickly fix the problem.
Please don't open an issue to or discuss this security vulnerability in a public place. Thanks for understanding!
Please don't open an issue to or discuss this security vulnerability in a public place. Thanks for understanding!

View File

@ -1,48 +1,91 @@
#!/usr/bin/env node
/**
* Usage examples:
* - Java (with custom timeout): node cli.js -j --host="mc.hypixel.net" --timeout 1000
* - Bedrock: node cli.js -b --host="play.timecrack.net"
*/
import { pingBedrock, pingJava } from '../index.js';
import { pingBedrock, pingJava } from "../index.js";
const args = getArgs();
const DEFAULT_TIMEOUT = 5000;
const JAVA_DEFAULT_PORT = 25565;
const BEDROCK_DEFAULT_PORT = 19132;
if (shouldShowHelp(args)) {
printHelp();
process.exit(1);
try {
const args = parseArgs(process.argv.slice(2));
if (shouldShowHelp(args)) {
printHelp();
process.exit(0);
}
validateArgs(args);
const port = Number(args.port) || getDefaultPort(args);
const timeout = Number(args.timeout) || DEFAULT_TIMEOUT;
if (args.j) {
await pingJavaServer(args.host, port, timeout);
} else if (args.b) {
await pingBedrockServer(args.host, port, timeout);
}
} catch (err) {
console.error(`ERROR: ${err.message}`);
process.exit(1);
}
if (!args.host) {
console.error('ERROR: The host argument not found! Use -h or --help.');
process.exit(1);
function parseArgs(rawArgs) {
const args = {};
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i];
if (arg.startsWith("--")) {
// Handle --key=value and --key value formats
const [key, value] = arg.slice(2).split("=");
args[key] = value ?? rawArgs[++i] ?? true;
} else if (arg.startsWith("-")) {
// Handle short flags (-j, -b, -h)
const flags = arg.slice(1).split("");
flags.forEach((flag) => {
args[flag] = true;
});
}
}
return args;
}
// easter egg <3
if (args.j && args.b) {
printInterestingFacts();
process.exit(0);
}
function validateArgs(args) {
if (args.j && args.b) {
printInterestingFacts();
process.exit(0);
}
const port = args.port || getDefaultPort(args);
const timeout = args.timeout || 500;
if (!args.host) {
throw new Error("The host argument not found! Use -h or --help.");
}
if (args.j) {
await pingJavaServer(args.host, port, timeout)
.catch(err => console.error(`ERROR: ${err.message}`));
} else if (args.b) {
await pingBedrockServer(args.host, port, timeout)
.catch(err => console.error(`ERROR: ${err.message}`));
} else {
console.error('ERROR: Unsupported flag passed. Use -h or --help.');
if (!args.j && !args.b) {
throw new Error("Must specify either -j or -b flag. Use -h or --help.");
}
if (args.port && (isNaN(args.port) || args.port < 1 || args.port > 65535)) {
throw new Error("Port must be a number between 1 and 65535");
}
if (args.timeout && (isNaN(args.timeout) || args.timeout < 0)) {
throw new Error("Timeout must be a positive number");
}
}
function shouldShowHelp(args) {
return args.help || args.h || Object.keys(args).length === 0;
return args.help || args.h || Object.keys(args).length === 0;
}
function printHelp() {
console.log(`node cli.js [..]
console.log(`node cli.js [..]
A simple to use, efficient, and full-featured Minecraft server info parser!
USAGE:
@ -51,12 +94,17 @@ function printHelp() {
OPTIONS:
-j Use for Minecraft Java Edition
-b Use for Minecraft Bedrock Edition
-h, --help Show this help message
P.S. Don't use them at the same time!`);
--host The server address (required)
--port The server port (default: ${JAVA_DEFAULT_PORT} for Java, ${BEDROCK_DEFAULT_PORT} for Bedrock)
--timeout The socket timeout in milliseconds (default: ${DEFAULT_TIMEOUT})
P.S. Don't use -j and -b at the same time!`);
}
function printInterestingFacts() {
console.log(`Some interesting facts about MOTDs on bedrock:
console.log(`Some interesting facts about MOTDs on bedrock:
- so far they seem to exclusively use legacy color codes
- the random style has a special impl for periods, they turn into animated
colons that warp up and down rapidly
@ -66,37 +114,27 @@ function printInterestingFacts() {
}
function getDefaultPort(args) {
return args.j ? 25565 : 19132;
return args.j ? JAVA_DEFAULT_PORT : BEDROCK_DEFAULT_PORT;
}
async function pingJavaServer(host, port, timeout) {
const data = await pingJava(host, { port, timeout });
console.log(`host: ${host}\nprotocol: ${data.version?.protocol}\nonline: ${data.players?.online}`);
const data = await pingJava(host, { port, timeout });
console.log(`Host: ${host}
Version: ${data.version?.name} (protocol: ${data.version?.protocol})
Players: ${data.players?.online}/${data.players?.max}
Description: ${
typeof data.description === "string"
? data.description
: data.description?.text
}`);
}
async function pingBedrockServer(host, port, timeout) {
const data = await pingBedrock(host, { port, timeout });
console.log(`host: ${host}\nprotocol: ${data.version.protocol}\nonline: ${data.players.online}`);
const data = await pingBedrock(host, { port, timeout });
console.log(`Host: ${host}
Edition: ${data.edition}
Version: ${data.version.minecraftVersion} (protocol: ${data.version.protocolVersion})
Players: ${data.players.online}/${data.players.max}
Name: ${data.name}
Gamemode: ${data.gameMode}`);
}
// parsing command line arguments
function getArgs() {
const args = {};
process.argv.slice(2).forEach(arg => {
// long arg
if (arg.slice(0, 2) === '--') {
const longArg = arg.split('=');
const longArgFlag = longArg[0].slice(2, longArg[0].length);
const longArgValue = longArg.length > 1 ? longArg[1] : true;
args[longArgFlag] = longArgValue;
// flags
} else if (arg[0] === '-') {
const flags = arg.slice(1, arg.length).split('');
flags.forEach(flag => {
args[flag] = true;
});
}
});
return args;
}

View File

@ -1,5 +1,5 @@
import { pingBedrock } from "../index.js";
const host = "mc.nevertime.su";
const ping = await pingBedrock(host);
console.log(ping);
const host = "0.0.0.0";
const motd = await pingBedrock(host);
console.log(motd);

View File

@ -1,6 +1,6 @@
/**
* Implementation of the RakNet ping/pong protocol.
* @see https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Raknet_Protocol
* @see https://minecraft.wiki/w/RakNet
*/
"use strict";
@ -9,146 +9,238 @@ import dgram from "node:dgram";
import crypto from "node:crypto";
const MAGIC = "00ffff00fefefefefdfdfdfd12345678";
const START_TIME = new Date().getTime();
const START_TIME = Date.now();
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}
* @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
*/
/**
* 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
*/
/**
* Creates an Unconnected Ping packet.
* @param {number} pingId
* @param {number} timestamp - The current time delta since the script started
* @returns {Buffer}
* @see {@link https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Raknet_Protocol#Unconnected_Ping}
* @see {@link https://minecraft.wiki/w/RakNet#Unconnected_Ping}
*/
const createUnconnectedPingFrame = (timestamp) => {
const buffer = Buffer.alloc(33);
buffer.writeUInt8(0x01, 0); // Packet ID
buffer.writeBigInt64LE(BigInt(timestamp), 1); // Timestamp
Buffer.from(MAGIC, "hex").copy(buffer, 9); // OFFLINE_MESSAGE_DATA_ID (Magic)
Buffer.from(MAGIC, "hex").copy(buffer, 9); // OFFLINE_MESSAGE_DATA_ID (Magic bytes)
Buffer.from(crypto.randomBytes(8)).copy(buffer, 25); // Client GUID
return buffer;
};
/**
* Extract Modt from Unconnected Pong Packet and convert to an object
* @param {Buffer} unconnectedPongPacket
* @returns {Object}
* @see {@link https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Raknet_Protocol#Unconnected_Pong}
* 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
*/
const extractModt = (unconnectedPongPacket) => {
// Skip everything to Modt
const offset = 33;
const length = unconnectedPongPacket.readUInt16BE(offset);
let modt = unconnectedPongPacket.toString(
"utf-8",
offset + 2,
offset + 2 + length
);
const parseMotd = (motdString) => {
const parts = motdString.split(";");
const components = modt.split(";");
const parsedComponents = {
edition: components[0],
name: components[1],
version: {
protocolVersion: Number(components[2]),
minecraftVersion: components[3],
},
players: {
online: Number(components[4]),
max: Number(components[5]),
},
serverId: components[6],
mapName: components[7],
gameMode: components[8],
};
return parsedComponents;
};
/**
* Sends a ping request to the specified host and port.
* @param {string} host - The IP address or hostname of the server.
* @param {number} [port=19132] - The port number.
* @param {function} cb - The callback function to handle the response.
* @param {number} [timeout=5000] - The timeout duration in milliseconds.
*/
const ping = (host, port = 19132, cb, timeout = 5000) => {
const socket = dgram.createSocket("udp4");
// 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);
const closeSocket = () => {
socket.close();
clearTimeout(timeoutTask);
};
// 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) => {
closeSocket();
if (!didFireError) {
didFireError = true;
cb(null, err);
}
};
try {
const ping = createUnconnectedPingFrame(new Date().getTime() - START_TIME);
socket.send(ping, 0, ping.length, port, host);
} catch (err) {
handleError(err);
if (parts.length < 5) {
throw new Error(
`Invalid MOTD format: Expected at least 5 fields, but got ${parts.length}.`
);
}
socket.on("message", (pongPacket) => {
const id = pongPacket[0];
const [
edition,
name,
protocolStr,
version,
playerCountStr,
playerMaxStr,
serverGuidStr,
subName,
gamemode,
nintendoLimitedStr,
port,
ipv6Port,
editorModeStr,
] = parts;
switch (id) {
case 0x1c: {
const modtObject = extractModt(pongPacket);
closeSocket();
cb(modtObject, null);
break;
}
let nintendoLimited;
if (nintendoLimitedStr === "0") {
nintendoLimited = true;
} else if (nintendoLimitedStr === "1") {
nintendoLimited = false;
}
default: {
handleError(new Error("Received unexpected packet"));
break;
}
}
});
socket.on("error", handleError);
return {
edition,
name,
protocol: Number(protocolStr),
version,
playerCount: Number(playerCountStr),
playerMax: Number(playerMaxStr),
serverGuid: BigInt(serverGuidStr),
subName,
gamemode,
nintendoLimited,
port: port ? Number(port) : undefined,
ipv6Port: ipv6Port ? Number(ipv6Port) : undefined,
editorMode: editorModeStr ? Boolean(Number(editorModeStr)) : undefined,
};
};
/**
* Asynchronously ping Minecraft Bedrock server.
* The optional `options` argument can be an object with a `ping` (default is `19132`) or/and `timeout` (default is `5000`) property.
* @param {string} host The Bedrock server address.
* @param {import('../types/index.js').PingOptions} options The configuration for pinging Minecraft Bedrock server.
* @returns {Promise<import('../types/index.js').BedrockPingResponse>}
* Transforms the raw MOTD object into a user-friendly, nested structure.
* @param {BedrockMotd} motd - The parsed MOTD object
* @returns {BedrockPingResponse}
*/
const transformMotd = (motd) => {
return {
edition: motd.edition,
name: motd.name,
levelName: motd.subName,
gamemode: motd.gamemode,
version: {
protocol: motd.protocol,
minecraft: motd.version,
},
players: {
online: motd.playerCount,
max: motd.playerMax,
},
port: {
v4: motd.port,
v6: motd.ipv6Port,
},
guid: motd.serverGuid,
isNintendoLimited: motd.nintendoLimited,
isEditorModeEnabled: motd.editorMode,
};
};
/**
* 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
*/
const parseUnconnectedPong = (pongPacket) => {
if (!Buffer.isBuffer(pongPacket) || pongPacket.length < 35) {
throw new Error("Invalid pong packet: buffer is too small.");
}
const packetId = pongPacket.readUInt8(0);
if (packetId !== UNCONNECTED_PONG) {
throw new Error(
`Unexpected packet ID: 0x${packetId.toString(16)}. Expected 0x1c.`
);
}
// The MOTD string is prefixed with its length as a 16-bit big-endian integer
const motdLength = pongPacket.readUInt16BE(33);
const motdOffset = 35;
if (motdOffset + motdLength > pongPacket.length) {
throw new Error("Malformed pong packet: MOTD length exceeds buffer size.");
}
const motdString = pongPacket.toString(
"utf-8",
motdOffset,
motdOffset + motdLength
);
const rawMotd = parseMotd(motdString);
const motd = transformMotd(rawMotd);
return motd;
};
/**
* 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
*/
export const pingBedrock = (host, options = {}) => {
if (!host) throw new Error("Host argument is not provided");
if (!host) {
throw new Error("Host argument is required.");
}
const { port = 19132, timeout = 5000 } = options;
return new Promise((resolve, reject) => {
ping(
host,
port,
(res, err) => {
err ? reject(err) : resolve(res);
},
timeout
);
const socket = dgram.createSocket("udp4");
// Prevent cleanup tasks from running more than once
// in case of multiple error callbacks
let isCleanupCompleted = false;
// Set a manual timeout interval to ensure
// the connection will NEVER hang regardless of internal state
const timeoutTask = setTimeout(() => {
socket.emit("error", new Error("Socket timeout"));
}, timeout);
// Idempotent function to handle cleanup tasks, we can safely call it multiple times without side effects
const cleanup = () => {
if (isCleanupCompleted) return;
isCleanupCompleted = true;
clearTimeout(timeoutTask);
socket.close();
};
// Generic error handler
socket.on("error", (err) => {
cleanup();
reject(err);
});
socket.on("message", (pongPacket) => {
try {
const motd = parseUnconnectedPong(pongPacket);
cleanup();
resolve(motd);
} catch (err) {
socket.emit("error", err);
}
});
try {
const pingPacket = createUnconnectedPingFrame(Date.now() - START_TIME);
socket.send(pingPacket, 0, pingPacket.length, port, host);
} catch (err) {
// Handle any immediate, synchronous errors that might occur when sending the ping packet
socket.emit("error", err);
}
});
};

View File

@ -17,7 +17,14 @@ import varint from "./varint.js";
* @param {number} [timeout=5000] The timeout duration in milliseconds.
* @param {number} [protocolVersion=-1] The protocol version of the Java client.
*/
function ping(host, virtualHost, port = 25565, cb, timeout = 5000, protocolVersion = -1) {
function ping(
host,
virtualHost,
port = 25565,
cb,
timeout = 5000,
protocolVersion = -1
) {
const socket = net.createConnection({ host, port });
// Set manual timeout interval.
@ -41,10 +48,9 @@ function ping(host, virtualHost, port = 25565, cb, timeout = 5000, protocolVersi
* @param {Error} err The error that occurred.
*/
const handleError = (err) => {
closeSocket();
if (!didFireError) {
didFireError = true;
closeSocket();
cb(null, err);
}
};
@ -132,7 +138,12 @@ function ping(host, virtualHost, port = 25565, cb, timeout = 5000, protocolVersi
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;
const {
port = 25565,
timeout = 5000,
protocolVersion = -1,
virtualHost = null,
} = options;
return new Promise((resolve, reject) => {
ping(

View File

@ -27,7 +27,7 @@ const varint = {
buf.writeUInt8(byte | 0x80, written++);
}
return buf.slice(0, written);
return buf.subarray(0, written);
},
/**
@ -66,65 +66,57 @@ const varint = {
},
/**
* Decodes a varint integer value from a byte 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) => {
let val = 0;
let count = 0;
while (true) {
const byte = buffer.readUInt8(offset++);
val |= (byte & 0x7f) << (count++ * 7);
if ((byte & 0x80) !== 0x80) {
break;
}
// Fast path for single-byte varints
const firstByte = buffer.readUInt8(offset);
if (firstByte < 0x80) {
return firstByte;
}
return val;
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 the number of bytes required to decode a varint integer value.
* @param {number} val - The varint integer value.
* @returns {5 | 7 | 8 | 1 | 2 | 3 | 4 | 6 | 9 | 10}
* 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) => {
// Constants representing the powers of 2 used for comparison
const N1 = Math.pow(2, 7);
const N2 = Math.pow(2, 14);
const N3 = Math.pow(2, 21);
const N4 = Math.pow(2, 28);
const N5 = Math.pow(2, 35);
const N6 = Math.pow(2, 42);
const N7 = Math.pow(2, 49);
const N8 = Math.pow(2, 56);
const N9 = Math.pow(2, 63);
// 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)
// Return the number of bytes required based on the value
return val < N1
? 1
: val < N2
? 2
: val < N3
? 3
: val < N4
? 4
: val < N5
? 5
: val < N6
? 6
: val < N7
? 7
: val < N8
? 8
: val < N9
? 9
: 10;
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;
},
};

1499
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,29 @@
{
"name": "@minescope/mineping",
"version": "1.6.0",
"description": "Ping both Minecraft Bedrock and Java servers.",
"main": "index.js",
"types": "types/index.d.ts",
"keywords": [],
"author": {
"name": "Timofey (xzeldon)",
"email": "contact@zeldon.ru",
"url": "https://zeldon.ru"
},
"repository": {
"type": "git",
"url": "git://github.com/minescope/mineping.git"
},
"type": "module",
"engines": {
"node": ">=14"
},
"license": "MIT"
}
"name": "@minescope/mineping",
"version": "1.7.0-beta.0",
"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"
},
"repository": {
"type": "git",
"url": "git://github.com/minescope/mineping.git"
},
"license": "MIT",
"keywords": [],
"author": {
"name": "Timofey Gelazoniya",
"email": "timofey@z4n.me",
"url": "https://zeldon.ru"
},
"engines": {
"node": ">=14"
},
"devDependencies": {
"vitest": "^3.2.3"
}
}

174
test/bedrock.test.js Normal file
View File

@ -0,0 +1,174 @@
import dgram from "node:dgram";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { pingBedrock } from "../lib/bedrock.js";
vi.mock("node:dgram");
describe("bedrock.js", () => {
let mockSocket;
beforeEach(() => {
// A store for event handlers, closed over by the mockSocket.
const handlers = {};
// Create a stateful mock socket to simulate EventEmitter.
mockSocket = {
send: vi.fn(),
close: vi.fn(),
on: vi.fn((event, handler) => {
handlers[event] = handler;
}),
emit: vi.fn((event, ...args) => {
if (handlers[event]) {
handlers[event](...args);
}
}),
};
dgram.createSocket = vi.fn().mockReturnValue(mockSocket);
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("should ping a 3rd party server and parse MOTD", async () => {
const host = "play.example.com";
const options = { port: 25565, timeout: 10000 };
const pingPromise = pingBedrock(host, options);
const motd =
"MCPE;§l§b§f  §eГриф§7, §cДуэли§7, §aКейсы;0;1337;1070;1999;-138584171542148188;oasys-pe.ru;Adventure;1";
const mockPongPacket = createMockPongPacket(motd);
mockSocket.emit("message", mockPongPacket);
const result = await pingPromise;
expect(dgram.createSocket).toHaveBeenCalledWith("udp4");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
33,
options.port,
host
);
expect(mockSocket.close).toHaveBeenCalled();
expect(result).toEqual({
edition: "MCPE",
name: "§l§b§f  §eГриф§7, §cДуэли§7, §aКейсы",
levelName: "oasys-pe.ru",
gamemode: "Adventure",
version: {
protocol: 0,
minecraft: "1337",
},
players: {
online: 1070,
max: 1999,
},
port: {
v4: undefined,
v6: undefined,
},
guid: -138584171542148188n,
isNintendoLimited: false,
isEditorModeEnabled: undefined,
});
});
it("should ping a BDS server with default `server.properties` and parse MOTD", async () => {
const host = "play.example.com";
const options = { port: 25565, timeout: 10000 };
const pingPromise = pingBedrock(host, options);
const motd =
"MCPE;Dedicated Server;800;1.21.84;0;10;11546321190880321782;Bedrock level;Survival;1;19132;19133;0;";
const mockPongPacket = createMockPongPacket(motd);
mockSocket.emit("message", mockPongPacket);
const result = await pingPromise;
expect(dgram.createSocket).toHaveBeenCalledWith("udp4");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
33,
options.port,
host
);
expect(mockSocket.close).toHaveBeenCalled();
expect(result).toEqual({
edition: "MCPE",
name: "Dedicated Server",
levelName: "Bedrock level",
gamemode: "Survival",
version: {
protocol: 800,
minecraft: "1.21.84",
},
players: {
online: 0,
max: 10,
},
port: {
v4: 19132,
v6: 19133,
},
guid: 11546321190880321782n,
isNintendoLimited: false,
isEditorModeEnabled: false,
});
});
describe("errors", () => {
it("should throw an error if host is not provided", () => {
expect(() => pingBedrock(null)).toThrow("Host argument is required");
});
it("should reject on socket timeout", async () => {
const pingPromise = pingBedrock("play.example.com", { timeout: 1000 });
vi.advanceTimersByTime(1000);
await expect(pingPromise).rejects.toThrow("Socket timeout");
expect(mockSocket.close).toHaveBeenCalled();
});
it("should reject on a generic socket error", async () => {
const pingPromise = pingBedrock("play.example.com");
// Simulate a DNS or network error by emitting it.
mockSocket.emit("error", new Error("EHOSTUNREACH"));
await expect(pingPromise).rejects.toThrow("EHOSTUNREACH");
});
it("should only reject once, even if multiple errors occur", async () => {
const pingPromise = pingBedrock("play.example.com");
// Fire a socket error first.
mockSocket.emit("error", new Error("First error"));
// Then, try to trigger another error by sending a bad message.
mockSocket.emit("message", Buffer.alloc(0));
await expect(pingPromise).rejects.toThrow("First error");
expect(mockSocket.close).toHaveBeenCalledTimes(1);
});
});
});
function createMockPongPacket(motd) {
const motdBuffer = Buffer.from(motd, "utf-8");
const packet = Buffer.alloc(35 + motdBuffer.length);
packet.writeUInt8(0x1c, 0);
packet.writeBigInt64LE(BigInt(Date.now()), 1);
Buffer.from("00ffff00fefefefefdfdfdfd12345678", "hex").copy(packet, 17);
packet.writeUInt16BE(motdBuffer.length, 33);
motdBuffer.copy(packet, 35);
return packet;
}

112
test/java.test.js Normal file
View File

@ -0,0 +1,112 @@
import net from "node:net";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { pingJava } from "../lib/java.js";
import varint from "../lib/varint.js";
vi.mock("node:net");
describe("pingJava", () => {
let mockSocket;
beforeEach(() => {
const mockHandlers = {};
mockSocket = {
write: vi.fn(),
destroy: vi.fn(),
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);
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
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 pingPromise = pingJava(host, options);
mockSocket.emit("connect");
expect(net.createConnection).toHaveBeenCalledWith({
host,
port: options.port,
});
expect(mockSocket.setNoDelay).toHaveBeenCalledWith(true);
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);
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 reject on socket timeout before data is received", async () => {
const pingPromise = pingJava("localhost", { timeout: 1000 });
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
mockSocket.emit("error", new Error("ECONNREFUSED"));
await expect(pingPromise).rejects.toThrow("ECONNREFUSED");
});
it("should only reject once, even if multiple errors occur", async () => {
const pingPromise = pingJava("localhost");
// Fire two errors back-to-back
mockSocket.emit("error", new Error("First error"));
mockSocket.emit("error", new Error("Second error"));
await expect(pingPromise).rejects.toThrow("First error");
expect(mockSocket.destroy).toHaveBeenCalledTimes(1);
});
});
});
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]);
}

76
test/varint.test.js Normal file
View File

@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import varint from "../lib/varint.js";
describe("varint.js", () => {
it("should encode and decode integers symmetrically (round-trip)", () => {
const testValues = [
0,
1,
127, // Max 1-byte
128, // Min 2-byte
255,
16383, // Max 2-byte
16384, // Min 3-byte
2147483647, // Max signed 32-bit int
-1, // Critical edge case (encodes as max unsigned int)
];
testValues.forEach((value) => {
const encoded = varint.encodeInt(value);
const decoded = varint.decodeInt(encoded, 0);
expect(decoded, `Value ${value} failed round-trip`).toBe(value);
});
});
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);
});
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"
);
});
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);
expect(decodedPacketLength).toBe(payload.length);
const lengthOfPacketLength = varint.decodeLength(decodedPacketLength);
const decodedPayload = finalPacket.subarray(lengthOfPacketLength);
expect(decodedPayload).toEqual(payload);
});
});

2
types/index.d.ts vendored
View File

@ -1,2 +0,0 @@
export * from "./lib/java.js";
export * from "./lib/bedrock.js";

View File

@ -1,53 +0,0 @@
/**
* @param port The server port.
* @param timeout The read/write socket timeout.
*/
export type BedrockPingOptions = {
port?: number;
timeout?: number;
};
export type BedrockPingResponse = {
edition: string;
name: string;
version: {
protocolVersion: number;
minecraftVersion: string;
};
players: {
online: number;
max: number;
};
serverId: string;
mapName: string;
gameMode: string;
};
/**
* Asynchronously ping Minecraft Bedrock server.
*
* The optional `options` argument can be an object with a `ping` (default is `19132`) or/and `timeout` (default is `5000`) property.
*
* @param host The Bedrock server address.
* @param options The configuration for pinging Minecraft Bedrock server.
*
* ```js
* import { pingBedrock } from '@minescope/mineping';
*
* const data = await pingBedrock('mco.mineplex.com');
* console.log(data);
* ```
*
* The resulting output will resemble:
* ```console
* {
* version: { name: 'Mineplex', protocol: '475' },
* players: { max: '5207', online: '5206' },
* description: ' New Costumes',
* gamemode: 'Survival'
* }
* ```
* @see [source](https://github.com/minescope/mineping/blob/915edbec9c9ad811459458600af3531ec0836911/lib/bedrock.js#L204)
*/
export function pingBedrock(host: string, options?: BedrockPingOptions): Promise<BedrockPingResponse>;

83
types/lib/java.d.ts vendored
View File

@ -1,83 +0,0 @@
/**
* @param port The server port.
* @param timeout The read/write socket timeout.
* @param protocolVersion The protocol version.
*/
export type JavaPingOptions = {
port?: number | undefined;
timeout?: number | undefined;
protocolVersion?: number | undefined;
virtualHost?: string | undefined;
};
/**
* JSON format chat component used for description field.
* @see https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Chat
*/
export type ChatComponent = {
text: string;
bold?: boolean;
italic?: boolean;
underlined?: boolean;
strikethrough?: boolean;
obfuscated?: boolean;
color?: string;
extra?: ChatComponent[];
};
export type SampleProp = {
name: string;
id: string;
};
/**
* `JSON Response` field of Response packet.
* @see https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Server_List_Ping#Status_Response
*/
export type JavaPingResponse = {
version: {
name: string;
protocol: number;
};
players: {
max: number;
online: number;
sample?: SampleProp[];
};
description: string | ChatComponent;
favicon?: string;
enforcesSecureChat?: boolean;
previewsChat?: boolean;
};
/**
* Asynchronously ping Minecraft Java server.
*
* The optional `options` argument can be an object with a `ping` (default is `25565`) or/and `timeout` (default is `5000`) property.
*
* @param host The Java server address.
* @param options The configuration for pinging Minecraft Java server.
*
* ```js
* import { pingJava } from '@minescope/mineping';
*
* const data = await pingJava('mc.hypixel.net');
* console.log(data);
* ```
*
* The resulting output will resemble:
* ```console
* {
* version: { name: 'Requires MC 1.8 / 1.18', protocol: 47 },
* players: { max: 200000, online: 67336, sample: [] },
* description: ' §f☃ §aHypixel Network §eTRIPLE COINS & EXP §f☃\n' +
* ' §6✰ §f§lHOLIDAY SALE §c§lUP TO 85% OFF §6✰',
* favicon: 'data:image/png;base64,iVBORw0KGg...
}
* ```
* @see [source](https://github.com/minescope/mineping/blob/915edbec9c9ad811459458600af3531ec0836911/lib/java.js#L117)
*/
export function pingJava(
host: string,
options?: JavaPingOptions
): Promise<JavaPingResponse>;

10
types/lib/varint.d.ts vendored
View File

@ -1,10 +0,0 @@
export default varint;
declare namespace varint {
function encodeInt(val: number): Buffer;
function encodeString(val: string): Buffer;
function encodeUShort(val: number): Buffer;
function concat(chunks: Buffer[]): Buffer;
function decodeInt(buffer: Buffer, offset: number): number;
function decodeString(val: Buffer, offset?: number): string;
function decodeLength(val: number): 5 | 7 | 8 | 1 | 2 | 3 | 4 | 6 | 9 | 10;
}