mirror of
https://github.com/minescope/mineping.git
synced 2025-07-18 18:06:36 +03:00
Compare commits
17 Commits
v1.6.0
...
1e6b5b3973
Author | SHA1 | Date | |
---|---|---|---|
1e6b5b3973
|
|||
23299a9a07
|
|||
2bd5d9c9bf
|
|||
7248a0096c
|
|||
a1b999ca4e
|
|||
7322034aba
|
|||
c7b99cb6db
|
|||
51b4771305
|
|||
3c2c049c19
|
|||
cbaa1a3e3e
|
|||
435e59739c
|
|||
ef2bebe755
|
|||
27011d4091
|
|||
d8d4a9a467
|
|||
d90a916fa5
|
|||
0959403b1b
|
|||
c71236f223
|
21
README.md
21
README.md
@ -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', {
|
||||
const data = await pingBedrock("mco.mineplex.com", {
|
||||
port: 19132,
|
||||
timeout: 500
|
||||
timeout: 500,
|
||||
});
|
||||
console.log(data);
|
||||
```
|
||||
|
@ -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!
|
128
example/cli.js
128
example/cli.js
@ -1,40 +1,83 @@
|
||||
#!/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)) {
|
||||
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) {
|
||||
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) {
|
||||
@ -51,8 +94,13 @@ 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() {
|
||||
@ -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}`);
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
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}`);
|
||||
}
|
@ -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);
|
||||
|
319
lib/bedrock.js
319
lib/bedrock.js
@ -1,154 +1,261 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 = 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 [`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} [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 - 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} pingId
|
||||
* 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/Minecraft_Wiki:Projects/wiki.vg_merge/Raknet_Protocol#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.
|
||||
* @returns {BedrockMotd} The parsed internal MOTD object.
|
||||
* @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(";");
|
||||
|
||||
if (parts.length < 5) {
|
||||
throw new Error(
|
||||
`Invalid MOTD format: Expected at least 5 fields, but got ${parts.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
const [
|
||||
edition,
|
||||
name,
|
||||
protocolStr,
|
||||
version,
|
||||
playerCountStr,
|
||||
playerMaxStr,
|
||||
serverGuidStr,
|
||||
subName,
|
||||
gamemode,
|
||||
nintendoLimitedStr,
|
||||
port,
|
||||
ipv6Port,
|
||||
editorModeStr,
|
||||
] = parts;
|
||||
|
||||
let nintendoLimited;
|
||||
if (nintendoLimitedStr === "0") {
|
||||
nintendoLimited = true;
|
||||
} else if (nintendoLimitedStr === "1") {
|
||||
nintendoLimited = false;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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.
|
||||
* Transforms the raw MOTD object into a user-friendly, nested structure.
|
||||
* @param {BedrockMotd} motd - The parsed MOTD object.
|
||||
* @returns {BedrockPingResponse} The final, user-facing response object.
|
||||
*/
|
||||
const ping = (host, port = 19132, cb, timeout = 5000) => {
|
||||
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} The final response object.
|
||||
* @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
|
||||
);
|
||||
debug("received raw MOTD string: %s", motdString);
|
||||
|
||||
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 {BedrockPingOptions} [options={}] - Optional configuration.
|
||||
* @returns {Promise<BedrockPingResponse>} A promise that resolves with the server's parsed MOTD.
|
||||
*/
|
||||
export async function pingBedrock(host, options = {}) {
|
||||
if (!host) {
|
||||
throw new Error("Host argument is required.");
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// Set manual timeout interval.
|
||||
// This ensures the connection will NEVER hang regardless of internal state
|
||||
// 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);
|
||||
|
||||
const closeSocket = () => {
|
||||
socket.close();
|
||||
// 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", host, port);
|
||||
clearTimeout(timeoutTask);
|
||||
socket.close();
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
socket.on("error", (err) => {
|
||||
debug("socket error for %s:%d - %s", host, port, err.message);
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.on("message", (pongPacket) => {
|
||||
const id = pongPacket[0];
|
||||
|
||||
switch (id) {
|
||||
case 0x1c: {
|
||||
const modtObject = extractModt(pongPacket);
|
||||
closeSocket();
|
||||
cb(modtObject, null);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
handleError(new Error("Received unexpected packet"));
|
||||
break;
|
||||
}
|
||||
debug("received %d bytes from %s:%d", pongPacket.length, host, port);
|
||||
try {
|
||||
const motd = parseUnconnectedPong(pongPacket);
|
||||
cleanup();
|
||||
resolve(motd);
|
||||
} catch (err) {
|
||||
socket.emit("error", err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", handleError);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
*/
|
||||
export const pingBedrock = (host, options = {}) => {
|
||||
if (!host) throw new Error("Host argument is not provided");
|
||||
|
||||
const { port = 19132, timeout = 5000 } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ping(
|
||||
host,
|
||||
port,
|
||||
(res, err) => {
|
||||
err ? reject(err) : resolve(res);
|
||||
},
|
||||
timeout
|
||||
);
|
||||
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
|
||||
socket.emit("error", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
344
lib/java.js
344
lib/java.js
@ -1,149 +1,267 @@
|
||||
/**
|
||||
* 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 { Resolver } 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
|
||||
/**
|
||||
* @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 with %dms timeout",
|
||||
host,
|
||||
timeout
|
||||
);
|
||||
const resolver = new Resolver({ timeout, tries: 3 });
|
||||
const srvRecords = await resolver.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, ENOTFOUND, or a DNS timeout (ETIMEOUT) are expected
|
||||
// when a server does not have an SRV record, so we ignore them and proceed.
|
||||
const nonFatalDnsCodes = ["ENODATA", "ENOTFOUND", "ETIMEOUT"];
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
nonFatalDnsCodes.includes(err.code)
|
||||
) {
|
||||
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(() => {
|
||||
socket.emit("error", new Error("Socket timeout"));
|
||||
}, timeout);
|
||||
|
||||
const closeSocket = () => {
|
||||
socket.destroy();
|
||||
// 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);
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
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", () => {
|
||||
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);
|
||||
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]);
|
||||
|
||||
// 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);
|
||||
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) {
|
||||
handleError(err);
|
||||
}
|
||||
} else {
|
||||
handleError(new Error("Received unexpected packet"));
|
||||
socket.emit("error", err);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
195
lib/varint.js
195
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 = {
|
||||
/**
|
||||
* 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
|
||||
export function encodeVarInt(value) {
|
||||
const buf = Buffer.alloc(5);
|
||||
let written = 0;
|
||||
let val = value;
|
||||
|
||||
while (true) {
|
||||
const byte = val & 0x7f;
|
||||
@ -25,107 +40,99 @@ const varint = {
|
||||
}
|
||||
|
||||
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.slice(0, written);
|
||||
},
|
||||
return buf.subarray(0, written);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a string value into a UTF-8 byte buffer.
|
||||
* @param {string} val - The string value to encode.
|
||||
/**
|
||||
* Encodes a string into a UTF-8 buffer.
|
||||
* @param {string} value The string to encode
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
encodeString: (val) => {
|
||||
return Buffer.from(val, "utf-8");
|
||||
},
|
||||
export function encodeString(value) {
|
||||
return Buffer.from(value, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an unsigned short value into a byte buffer.
|
||||
* @param {number} val - The unsigned short value to encode.
|
||||
/**
|
||||
* Encodes an unsigned short (16-bit big-endian) into a 2-byte buffer.
|
||||
* @param {number} value The number to encode
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
encodeUShort: (val) => {
|
||||
return Buffer.from([val >> 8, val & 0xff]);
|
||||
},
|
||||
export function encodeUShort(value) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16BE(value, 0);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates multiple byte buffers into a single byte buffer.
|
||||
* @param {Buffer[]} chunks - An array of byte buffers to concatenate.
|
||||
* @returns {Buffer}
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
concat: (chunks) => {
|
||||
let length = 0;
|
||||
export function concatPackets(chunks) {
|
||||
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.
|
||||
const firstByte = buffer.readUInt8(offset);
|
||||
if ((firstByte & 0x80) === 0) {
|
||||
return { value: firstByte, bytesRead: 1 };
|
||||
}
|
||||
|
||||
return Buffer.concat(buffer);
|
||||
},
|
||||
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
|
||||
|
||||
/**
|
||||
* Decodes a varint integer value from a byte 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;
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const byte = buffer.readUInt8(offset++);
|
||||
const byte = buffer.readUInt8(currentOffset);
|
||||
bytesRead++;
|
||||
currentOffset++;
|
||||
|
||||
val |= (byte & 0x7f) << (count++ * 7);
|
||||
val |= (byte & 0x7f) << position;
|
||||
position += 7;
|
||||
|
||||
if ((byte & 0x80) !== 0x80) {
|
||||
break;
|
||||
if ((byte & 0x80) === 0) {
|
||||
return { value: val, bytesRead: bytesRead };
|
||||
}
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
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);
|
||||
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
|
||||
export default varint;
|
||||
throw new VarIntError(
|
||||
"VarInt is too big or malformed: 5 bytes read with continuation bit still set.",
|
||||
ERR_VARINT_MALFORMED
|
||||
);
|
||||
}
|
||||
|
1551
package-lock.json
generated
Normal file
1551
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@ -1,22 +1,45 @@
|
||||
{
|
||||
"name": "@minescope/mineping",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0-beta.3",
|
||||
"description": "Ping both Minecraft Bedrock and Java servers.",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"types": "types/index.d.ts",
|
||||
"keywords": [],
|
||||
"author": {
|
||||
"name": "Timofey (xzeldon)",
|
||||
"email": "contact@zeldon.ru",
|
||||
"url": "https://zeldon.ru"
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"types:check": "tsc --noEmit",
|
||||
"types:build": "tsc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/minescope/mineping.git"
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"minecraft",
|
||||
"raknet",
|
||||
"node",
|
||||
"mcpe",
|
||||
"mcbe",
|
||||
"ping",
|
||||
"bedrock"
|
||||
],
|
||||
"author": {
|
||||
"name": "Timofey Gelazoniya",
|
||||
"email": "timofey@z4n.me",
|
||||
"url": "https://zeldon.ru"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
174
test/bedrock.test.js
Normal file
174
test/bedrock.test.js
Normal 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§bOasys§fPE §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§bOasys§fPE §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;
|
||||
}
|
133
test/java.test.js
Normal file
133
test/java.test.js
Normal file
@ -0,0 +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 * 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(),
|
||||
// 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.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 };
|
||||
const pingPromise = pingJava(host, options);
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
describe("errors", () => {
|
||||
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", async () => {
|
||||
const pingPromise = pingJava("localhost", { timeout: 1000 });
|
||||
await vi.runAllTicks();
|
||||
mockSocket.emit("connect");
|
||||
vi.advanceTimersByTime(1000);
|
||||
await expect(pingPromise).rejects.toThrow("Socket timeout");
|
||||
});
|
||||
|
||||
it("should reject on connection error", async () => {
|
||||
const pingPromise = pingJava("localhost");
|
||||
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");
|
||||
await vi.runAllTicks();
|
||||
mockSocket.emit("error", new Error("First error"));
|
||||
mockSocket.emit("error", new Error("Second error")); // Should be ignored
|
||||
await expect(pingPromise).rejects.toThrow("First error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 = varint.encodeString(jsonString);
|
||||
const jsonLength = varint.encodeVarInt(jsonBuffer.length);
|
||||
const packetId = varint.encodeVarInt(0x00);
|
||||
const payloadParts = [packetId, jsonLength, jsonBuffer];
|
||||
return varint.concatPackets(payloadParts);
|
||||
}
|
61
test/varint.test.js
Normal file
61
test/varint.test.js
Normal file
@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as 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.encodeVarInt(value);
|
||||
const { value: decoded } = varint.decodeVarInt(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]);
|
||||
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.decodeVarInt(invalidBuffer, 0)).toThrow(
|
||||
"VarInt is too big or malformed"
|
||||
);
|
||||
});
|
||||
|
||||
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 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 decodedPayload = finalPacket.subarray(bytesRead);
|
||||
expect(decodedPayload).toEqual(payload);
|
||||
});
|
||||
});
|
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"include": ["lib/**/*.js", "index.js"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "types",
|
||||
"removeComments": false
|
||||
}
|
||||
}
|
4
types/index.d.ts
vendored
4
types/index.d.ts
vendored
@ -1,2 +1,2 @@
|
||||
export * from "./lib/java.js";
|
||||
export * from "./lib/bedrock.js";
|
||||
export { pingJava } from "./lib/java.js";
|
||||
export { pingBedrock } from "./lib/bedrock.js";
|
||||
|
162
types/lib/bedrock.d.ts
vendored
162
types/lib/bedrock.d.ts
vendored
@ -1,53 +1,131 @@
|
||||
/**
|
||||
* @param port The server port.
|
||||
* @param timeout The read/write socket timeout.
|
||||
* Asynchronously pings a Minecraft Bedrock server.
|
||||
* @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 function pingBedrock(host: string, options?: BedrockPingOptions): Promise<BedrockPingResponse>;
|
||||
/**
|
||||
* Representation of raw, semicolon-delimited MOTD string.
|
||||
* This struct directly mirrors the fields and order from the server response.
|
||||
* See [`Unconnected Pong Documentation`](https://minecraft.wiki/w/RakNet#Unconnected_Pong) for more details.
|
||||
*/
|
||||
export type BedrockMotd = {
|
||||
/**
|
||||
* - The edition of the server (MCPE or MCEE).
|
||||
*/
|
||||
export type BedrockPingOptions = {
|
||||
port?: number;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type BedrockPingResponse = {
|
||||
edition: string;
|
||||
/**
|
||||
* - The primary name of the server (first line of MOTD).
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* - The protocol version.
|
||||
*/
|
||||
protocol: number;
|
||||
/**
|
||||
* - The game version (e.g., "1.21.2").
|
||||
*/
|
||||
version: string;
|
||||
/**
|
||||
* - The current number of players online.
|
||||
*/
|
||||
playerCount: number;
|
||||
/**
|
||||
* - The maximum number of players allowed.
|
||||
*/
|
||||
playerMax: number;
|
||||
/**
|
||||
* - The server's GUID.
|
||||
*/
|
||||
serverGuid: bigint;
|
||||
/**
|
||||
* - The secondary name of the server (second line of MOTD).
|
||||
*/
|
||||
subName: string;
|
||||
/**
|
||||
* - The default gamemode (e.g., "Survival").
|
||||
*/
|
||||
gamemode: string;
|
||||
/**
|
||||
* - Whether the server is Nintendo limited.
|
||||
*/
|
||||
nintendoLimited?: boolean;
|
||||
/**
|
||||
* - The server's IPv4 port, if provided.
|
||||
*/
|
||||
port?: number;
|
||||
/**
|
||||
* - The server's IPv6 port, if provided.
|
||||
*/
|
||||
ipv6Port?: number;
|
||||
/**
|
||||
* - 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.
|
||||
*/
|
||||
editorMode?: boolean;
|
||||
};
|
||||
/**
|
||||
* Represents the structured and user-friendly response from a server ping.
|
||||
* This is the public-facing object that users of the library will receive.
|
||||
*/
|
||||
export type BedrockPingResponse = {
|
||||
/**
|
||||
* - The edition of the server (MCPE or MCEE).
|
||||
*/
|
||||
edition: string;
|
||||
/**
|
||||
* - The primary name of the server (first line of MOTD).
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* - The name of the world or level being hosted.
|
||||
*/
|
||||
levelName: string;
|
||||
/**
|
||||
* - The default gamemode of the server.
|
||||
*/
|
||||
gamemode: string;
|
||||
/**
|
||||
* - Game and protocol versions.
|
||||
*/
|
||||
version: {
|
||||
protocolVersion: number;
|
||||
minecraftVersion: string;
|
||||
protocol: number;
|
||||
minecraft: string;
|
||||
};
|
||||
/**
|
||||
* - Current and maximum player counts.
|
||||
*/
|
||||
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)
|
||||
/**
|
||||
* - Announced IPv4 and IPv6 ports.
|
||||
*/
|
||||
export function pingBedrock(host: string, options?: BedrockPingOptions): Promise<BedrockPingResponse>;
|
||||
|
||||
port: {
|
||||
v4?: number;
|
||||
v6?: number;
|
||||
};
|
||||
/**
|
||||
* - The server's unique 64-bit GUID.
|
||||
*/
|
||||
guid: bigint;
|
||||
/**
|
||||
* - True if the server restricts Nintendo Switch players.
|
||||
*/
|
||||
isNintendoLimited?: boolean;
|
||||
/**
|
||||
* - 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.
|
||||
*/
|
||||
isEditorModeEnabled?: boolean;
|
||||
};
|
||||
export type BedrockPingOptions = {
|
||||
/**
|
||||
* - The server port to ping.
|
||||
*/
|
||||
port?: number;
|
||||
/**
|
||||
* - The timeout in milliseconds for the request.
|
||||
*/
|
||||
timeout?: number;
|
||||
};
|
||||
|
120
types/lib/java.d.ts
vendored
120
types/lib/java.d.ts
vendored
@ -1,83 +1,63 @@
|
||||
/**
|
||||
* @param port The server port.
|
||||
* @param timeout The read/write socket timeout.
|
||||
* @param protocolVersion The protocol version.
|
||||
* 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 type JavaPingOptions = {
|
||||
port?: number | undefined;
|
||||
timeout?: number | undefined;
|
||||
protocolVersion?: number | undefined;
|
||||
virtualHost?: string | undefined;
|
||||
};
|
||||
|
||||
export function pingJava(host: string, options?: JavaPingOptions): Promise<JavaPingResponse>;
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
export type JavaPingResponse = {
|
||||
/**
|
||||
* - Contains the server's version name and protocol number.
|
||||
*/
|
||||
version: {
|
||||
name: string;
|
||||
protocol: number;
|
||||
};
|
||||
players: {
|
||||
/**
|
||||
* - Player count and a sample of online players.
|
||||
*/
|
||||
players?: {
|
||||
max: number;
|
||||
online: number;
|
||||
sample?: SampleProp[];
|
||||
sample?: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
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)
|
||||
/**
|
||||
* - The server's Message of the Day (MOTD).
|
||||
*/
|
||||
export function pingJava(
|
||||
host: string,
|
||||
options?: JavaPingOptions
|
||||
): Promise<JavaPingResponse>;
|
||||
description?: object | string;
|
||||
/**
|
||||
* - A Base64-encoded 64x64 PNG image data URI.
|
||||
*/
|
||||
favicon?: string;
|
||||
/**
|
||||
* - True if the server requires clients to have a Mojang-signed public key.
|
||||
*/
|
||||
enforcesSecureChat?: boolean;
|
||||
/**
|
||||
* - True if a mod is installed to disable chat reporting.
|
||||
*/
|
||||
preventsChatReports?: boolean;
|
||||
};
|
||||
export type JavaPingOptions = {
|
||||
/**
|
||||
* - The fallback port if an SRV record is not found.
|
||||
*/
|
||||
port?: number;
|
||||
/**
|
||||
* - The connection timeout in milliseconds.
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
* - The protocol version to use in the handshake. `-1` is for auto-detection.
|
||||
*/
|
||||
protocolVersion?: number;
|
||||
};
|
||||
|
57
types/lib/varint.d.ts
vendored
57
types/lib/varint.d.ts
vendored
@ -1,10 +1,49 @@
|
||||
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;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function encodeVarInt(value: number): Buffer;
|
||||
/**
|
||||
* Encodes a string into a UTF-8 buffer.
|
||||
* @param {string} value The string to encode
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function encodeString(value: string): Buffer;
|
||||
/**
|
||||
* 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: number): Buffer;
|
||||
/**
|
||||
* 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: Buffer[]): Buffer;
|
||||
/**
|
||||
* 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: Buffer, offset?: number): {
|
||||
value: number;
|
||||
bytesRead: number;
|
||||
};
|
||||
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: string, code: string);
|
||||
code: string;
|
||||
}
|
||||
|
Reference in New Issue
Block a user