mirror of
https://github.com/minescope/mineping.git
synced 2025-07-12 13:44:36 +03:00
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.
This commit is contained in:
308
lib/bedrock.js
308
lib/bedrock.js
@ -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,168 +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 Motd from Unconnected Pong Packet and convert to an object
|
||||
* @param {Buffer} unconnectedPongPacket
|
||||
* @returns {Object}
|
||||
* @throws {Error} If packet is malformed or invalid
|
||||
* @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 extractMotd = (unconnectedPongPacket) => {
|
||||
if (
|
||||
!Buffer.isBuffer(unconnectedPongPacket) ||
|
||||
unconnectedPongPacket.length < 35
|
||||
) {
|
||||
throw new Error("Invalid pong packet");
|
||||
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 offset = 33;
|
||||
const length = unconnectedPongPacket.readUInt16BE(offset);
|
||||
const [
|
||||
edition,
|
||||
name,
|
||||
protocolStr,
|
||||
version,
|
||||
playerCountStr,
|
||||
playerMaxStr,
|
||||
serverGuidStr,
|
||||
subName,
|
||||
gamemode,
|
||||
nintendoLimitedStr,
|
||||
port,
|
||||
ipv6Port,
|
||||
editorModeStr,
|
||||
] = parts;
|
||||
|
||||
// Check for buffer bounds
|
||||
if (offset + 2 + length > unconnectedPongPacket.length) {
|
||||
throw new Error("Malformed pong packet");
|
||||
let nintendoLimited;
|
||||
if (nintendoLimitedStr === "0") {
|
||||
nintendoLimited = true;
|
||||
} else if (nintendoLimitedStr === "1") {
|
||||
nintendoLimited = false;
|
||||
}
|
||||
|
||||
let motd = unconnectedPongPacket.toString(
|
||||
"utf-8",
|
||||
offset + 2,
|
||||
offset + 2 + length
|
||||
);
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
const components = motd.split(";");
|
||||
|
||||
// Validate required components
|
||||
if (components.length < 5) {
|
||||
throw new Error("Invalid MOTD format");
|
||||
}
|
||||
|
||||
const parsedComponents = {
|
||||
edition: components[0],
|
||||
name: components[1],
|
||||
/**
|
||||
* 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: {
|
||||
protocolVersion: Number(components[2]),
|
||||
minecraftVersion: components[3],
|
||||
protocol: motd.protocol,
|
||||
minecraft: motd.version,
|
||||
},
|
||||
players: {
|
||||
online: Number(components[4]),
|
||||
max: Number(components[5]),
|
||||
online: motd.playerCount,
|
||||
max: motd.playerMax,
|
||||
},
|
||||
serverId: components[6],
|
||||
mapName: components[7],
|
||||
gameMode: components[8],
|
||||
port: {
|
||||
v4: motd.port,
|
||||
v6: motd.ipv6Port,
|
||||
},
|
||||
guid: motd.serverGuid,
|
||||
isNintendoLimited: motd.nintendoLimited,
|
||||
isEditorModeEnabled: motd.editorMode,
|
||||
};
|
||||
|
||||
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.
|
||||
* 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 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) => {
|
||||
if (!didFireError) {
|
||||
didFireError = true;
|
||||
closeSocket();
|
||||
cb(null, err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ping = createUnconnectedPingFrame(new Date().getTime() - START_TIME);
|
||||
socket.send(ping, 0, ping.length, port, host);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
const parseUnconnectedPong = (pongPacket) => {
|
||||
if (!Buffer.isBuffer(pongPacket) || pongPacket.length < 35) {
|
||||
throw new Error("Invalid pong packet: buffer is too small.");
|
||||
}
|
||||
|
||||
socket.on("message", (pongPacket) => {
|
||||
if (!Buffer.isBuffer(pongPacket) || pongPacket.length === 0) {
|
||||
handleError(new Error("Invalid packet received"));
|
||||
return;
|
||||
}
|
||||
const packetId = pongPacket.readUInt8(0);
|
||||
if (packetId !== UNCONNECTED_PONG) {
|
||||
throw new Error(
|
||||
`Unexpected packet ID: 0x${packetId.toString(16)}. Expected 0x1c.`
|
||||
);
|
||||
}
|
||||
|
||||
const id = pongPacket[0];
|
||||
if (id !== UNCONNECTED_PONG) {
|
||||
handleError(new Error(`Unexpected packet ID: 0x${id.toString(16)}`));
|
||||
return;
|
||||
}
|
||||
// The MOTD string is prefixed with its length as a 16-bit big-endian integer
|
||||
const motdLength = pongPacket.readUInt16BE(33);
|
||||
const motdOffset = 35;
|
||||
|
||||
try {
|
||||
const motdObject = extractMotd(pongPacket);
|
||||
closeSocket();
|
||||
cb(motdObject, null);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
if (motdOffset + motdLength > pongPacket.length) {
|
||||
throw new Error("Malformed pong packet: MOTD length exceeds buffer size.");
|
||||
}
|
||||
|
||||
socket.on("error", handleError);
|
||||
const motdString = pongPacket.toString(
|
||||
"utf-8",
|
||||
motdOffset,
|
||||
motdOffset + motdLength
|
||||
);
|
||||
|
||||
const rawMotd = parseMotd(motdString);
|
||||
const motd = transformMotd(rawMotd);
|
||||
return motd;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
Reference in New Issue
Block a user