From 3c2c049c19762f46b91975eb785b2dc1301d714e Mon Sep 17 00:00:00 2001 From: Timofey Gelazoniya Date: Mon, 16 Jun 2025 01:09:41 +0300 Subject: [PATCH] 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. --- example/single.js | 6 +- lib/bedrock.js | 308 +++++++++++++++++++++++++---------------- test/bedrock.test.js | 75 ++++++++-- types/index.d.ts | 2 - types/lib/bedrock.d.ts | 61 -------- types/lib/java.d.ts | 83 ----------- types/lib/varint.d.ts | 44 ------ 7 files changed, 258 insertions(+), 321 deletions(-) delete mode 100644 types/index.d.ts delete mode 100644 types/lib/bedrock.d.ts delete mode 100644 types/lib/java.d.ts delete mode 100644 types/lib/varint.d.ts diff --git a/example/single.js b/example/single.js index d3858e3..ff3831b 100644 --- a/example/single.js +++ b/example/single.js @@ -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); diff --git a/lib/bedrock.js b/lib/bedrock.js index 7154a58..0dc41cf 100644 --- a/lib/bedrock.js +++ b/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} + * 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} 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); + } }); }; diff --git a/test/bedrock.test.js b/test/bedrock.test.js index 3c3f5ef..8365e48 100644 --- a/test/bedrock.test.js +++ b/test/bedrock.test.js @@ -34,13 +34,13 @@ describe("bedrock.js", () => { vi.useRealTimers(); }); - it("should ping a server and parse MOTD", async () => { + 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;0;1337;1096;1999;-37530542056358113;oasys-pe.ru;Adventure;1"; + "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); @@ -58,18 +58,75 @@ describe("bedrock.js", () => { expect(mockSocket.close).toHaveBeenCalled(); expect(result).toEqual({ edition: "MCPE", - name: "§l§bOasys§fPE", - version: { protocolVersion: 0, minecraftVersion: "1337" }, - players: { online: 1096, max: 1999 }, - serverId: "-37530542056358113", - mapName: "oasys-pe.ru", - gameMode: "Adventure", + 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 not provided"); + expect(() => pingBedrock(null)).toThrow("Host argument is required"); }); it("should reject on socket timeout", async () => { diff --git a/types/index.d.ts b/types/index.d.ts deleted file mode 100644 index f0f7bc4..0000000 --- a/types/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./lib/java.js"; -export * from "./lib/bedrock.js"; \ No newline at end of file diff --git a/types/lib/bedrock.d.ts b/types/lib/bedrock.d.ts deleted file mode 100644 index 5b99e18..0000000 --- a/types/lib/bedrock.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @param port The server port (1-65535). - * @param timeout The read/write socket timeout in milliseconds. - */ -export type BedrockPingOptions = { - port?: number & { _brand: "Port" }; // 1-65535 - timeout?: number & { _brand: "Timeout" }; // > 0 -}; - -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. - * - * @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 - * { - * edition: "MCPE", - * name: "Mineplex", - * version: { - * protocolVersion: 475, - * minecraftVersion: "1.18.0" - * }, - * players: { - * online: 5206, - * max: 5207 - * }, - * serverId: "12345678", - * mapName: "Lobby", - * gameMode: "Survival" - * } - * ``` - */ -export function pingBedrock( - host: string, - options?: BedrockPingOptions -): Promise; diff --git a/types/lib/java.d.ts b/types/lib/java.d.ts deleted file mode 100644 index b6612fd..0000000 --- a/types/lib/java.d.ts +++ /dev/null @@ -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; diff --git a/types/lib/varint.d.ts b/types/lib/varint.d.ts deleted file mode 100644 index 84c9804..0000000 --- a/types/lib/varint.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -export default varint; -declare namespace varint { - /** - * Encodes an integer value into a varint byte buffer. - * @param val - The integer value to encode. - */ - function encodeInt(val: number): Buffer; - - /** - * Encodes a string value into a UTF-8 byte buffer. - * @param val - The string value to encode. - */ - function encodeString(val: string): Buffer; - - /** - * Encodes an unsigned short value into a byte buffer. - * @param val - The unsigned short value to encode. - */ - function encodeUShort(val: number): Buffer; - - /** - * Concatenates multiple byte buffers into a single byte buffer. - * @param chunks - An array of byte buffers to concatenate. - */ - function concat(chunks: Buffer[]): Buffer; - - /** - * Decodes a varint integer value from a buffer. - * @param buffer - The byte buffer to decode from. - * @param offset - The offset in the buffer to start decoding from. - */ - function decodeInt(buffer: Buffer, offset: number): number; - - /** - * 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 val - The number to calculate the VarInt length for. - * @returns The number of bytes needed to encode the value (1-5). - */ - function decodeLength(val: number): 1 | 2 | 3 | 4 | 5; -}