diff --git a/lib/java.js b/lib/java.js index ef0cd7d..00ac3a1 100644 --- a/lib/java.js +++ b/lib/java.js @@ -1,160 +1,222 @@ /** * Implementation of the Java Minecraft ping protocol. - * @see https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Server_List_Ping + * @see https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping */ "use strict"; import net from "node:net"; -import varint from "./varint.js"; +import dns from "node:dns/promises"; +import * as varint from "./varint.js"; /** - * 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 official protocol documentation. + * @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 }> } | undefined} players - Player count and a sample of online players. + * @property {object | string | undefined} description - Optional. The server's Message of the Day (MOTD) + * @property {string | undefined} favicon - Optional. A Base64-encoded 64x64 PNG image data URI + * @property {boolean | undefined} enforcesSecureChat - Optional. True if the server requires clients to have a Mojang-signed public key + * @property {boolean | undefined} preventsChatReports - Optional. True if a mod is installed to disable chat reporting + * @see {@link https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Status_Response} */ -function ping( - host, - virtualHost, - port = 25565, - cb, - timeout = 5000, - protocolVersion = -1 -) { - const socket = net.createConnection({ host, port }); - // Set manual timeout interval. - // This ensures the connection will NEVER hang regardless of internal state - const timeoutTask = setTimeout(() => { - socket.emit("error", new Error("Socket timeout")); - }, timeout); +/** + * 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 closeSocket = () => { - socket.destroy(); - clearTimeout(timeoutTask); - }; + 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 + ]; - // 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); - } - }; - - // #setNoDelay instantly flushes data during read/writes - // This prevents the runtime from delaying the write at all - socket.setNoDelay(true); - - socket.on("connect", () => { - const handshake = varint.concat([ - varint.encodeInt(0), - varint.encodeInt(protocolVersion), - varint.encodeInt(virtualHost.length), - varint.encodeString(virtualHost), - varint.encodeUShort(port), - varint.encodeInt(1), - ]); - - socket.write(handshake); - - const request = varint.concat([varint.encodeInt(0)]); - - socket.write(request); - }); - - let incomingBuffer = Buffer.alloc(0); - - socket.on("data", (data) => { - incomingBuffer = Buffer.concat([incomingBuffer, data]); - - // Wait until incomingBuffer is at least 5 bytes long to ensure it has captured the first VarInt value - // This value is used to determine the full read length of the response - // "VarInts are never longer than 5 bytes" - // https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Data_types#VarInt_and_VarLong - if (incomingBuffer.length < 5) { - return; - } - - let offset = 0; - const packetLength = varint.decodeInt(incomingBuffer, offset); - - // Ensure incomingBuffer contains the full response - if (incomingBuffer.length - offset < packetLength) { - return; - } - - const packetId = varint.decodeInt( - incomingBuffer, - varint.decodeLength(packetLength) - ); - - if (packetId === 0) { - const data = incomingBuffer.subarray( - varint.decodeLength(packetLength) + varint.decodeLength(packetId) - ); - const responseLength = varint.decodeInt(data, 0); - const response = data.subarray( - varint.decodeLength(responseLength), - varint.decodeLength(responseLength) + responseLength - ); - - try { - const message = JSON.parse(response); - - closeSocket(); - cb(message, null); - } catch (err) { - handleError(err); - } - } else { - handleError(new Error("Received unexpected packet")); - } - }); - - socket.on("error", handleError); + return varint.concatPackets(payload); } /** - * Asynchronously ping Minecraft Java server. - * The optional `options` argument can be an object with a `port` (default is `25565`) or/and `timeout` (default is `5000`) or/and `protocolVersion` (default is `-1`) property. - * @param {string} host The Java server address. - * @param {import('../types/index.js').PingOptions} options The configuration for pinging Minecraft Java server. - * @returns {Promise} + * Creates the Status Request packet. + * @returns {Buffer} The complete Status Request packet */ -export function pingJava(host, options = {}) { - if (!host) throw new Error("Host argument is not provided"); +function createStatusRequestPacket() { + const payload = [ + varint.encodeVarInt(0x00), // Packet ID + ]; + return varint.concatPackets(payload); +} + +/** + * Attempts to parse the server status response from the buffer. + * @param {Buffer} buffer The incoming data buffer + * @returns {{ response: JavaPingResponse, remainder: Buffer } | null} The parsed response and the remaining buffer, or null if the packet is incomplete + */ +function processResponse(buffer) { + let offset = 0; + + try { + const packetLengthResult = varint.decodeVarInt(buffer, offset); + const packetLength = packetLengthResult.value; + offset += packetLengthResult.bytesRead; + + // Check if the full packet has arrived yet. + if (buffer.length < offset + packetLength) { + 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) { + return null; // Incomplete JSON string, wait for more data. + } + + const jsonString = buffer + .subarray(offset, offset + jsonLength) + .toString("utf8"); + 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.code === varint.ERR_VARINT_BUFFER_UNDERFLOW) { + return null; // Wait for more data. + } + // For malformed VarInts or JSON, throw the error to reject the promise. + throw err; + } +} + +/** + * 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 {object} [options={}] Optional configuration + * @param {number} [options.port=25565] The fallback port if an SRV record is not found + * @param {number} [options.timeout=5000] The connection timeout in milliseconds + * @param {number} [options.protocolVersion=-1] The protocol version to use in the handshake. `-1` is for auto-detection + * @returns {Promise} A promise that resolves with the server's status + */ +export async function pingJava(host, options = {}) { + if (typeof host !== "string" || host.trim() === "") { + throw new Error("Host argument is required."); + } const { - port = 25565, + port: fallbackPort = 25565, timeout = 5000, protocolVersion = -1, - virtualHost = null, } = options; + let targetHost = host; + let targetPort = fallbackPort; + + try { + const srvRecords = await dns.resolveSrv(`_minecraft._tcp.${host}`); + if (srvRecords.length > 0) { + targetHost = srvRecords[0].name; + targetPort = srvRecords[0].port; + } + } catch (err) { + // Common errors like ENODATA or ENOTFOUND are expected when a server + // does not have an SRV record, so we ignore them and proceed. + if (!["ENODATA", "ENOTFOUND"].includes(err.code)) { + // For other errors we should re-throw. + throw err; + } + } + return new Promise((resolve, reject) => { - ping( - host, - virtualHost || host, - port, - (res, err) => { - err ? reject(err) : resolve(res); - }, - timeout, - protocolVersion - ); + const socket = net.createConnection({ host: targetHost, port: targetPort }); + + // Prevent cleanup tasks from running more than once + // in case of multiple error callbacks + let isCleanupCompleted = false; + + // Set a manual timeout interval to ensure + // the connection will NEVER hang regardless of internal state + const timeoutTask = setTimeout(() => { + socket.emit("error", new Error("Socket timeout")); + }, timeout); + + // Idempotent function to handle cleanup tasks, we can safely call it multiple times without side effects + const cleanup = () => { + if (isCleanupCompleted) return; + isCleanupCompleted = true; + clearTimeout(timeoutTask); + socket.destroy(); + }; + + // #setNoDelay instantly flushes data during read/writes + // This prevents the runtime from delaying the write at all + socket.setNoDelay(true); + + // Generic error handler + socket.on("error", (err) => { + cleanup(); + reject(err); + }); + + socket.on("close", () => { + if (!isCleanupCompleted) { + cleanup(); + reject(new Error("Socket closed unexpectedly without a response.")); + } + }); + + socket.on("connect", () => { + 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) => { + incomingBuffer = Buffer.concat([incomingBuffer, data]); + + try { + const result = processResponse(incomingBuffer); + if (result) { + // We successfully parsed a response. Clean up before resolving. + cleanup(); + resolve(result.response); + } + // If result is null, we just wait for more data to arrive. + } catch (err) { + socket.emit("error", err); + } + }); }); } diff --git a/lib/varint.js b/lib/varint.js index d800c69..84245d4 100644 --- a/lib/varint.js +++ b/lib/varint.js @@ -1,123 +1,138 @@ -// https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Protocol#Data_types +// https://minecraft.wiki/w/Java_Edition_protocol/Data_types + +"use strict"; + +export const ERR_VARINT_BUFFER_UNDERFLOW = "VARINT_BUFFER_UNDERFLOW"; +export const ERR_VARINT_MALFORMED = "VARINT_MALFORMED"; +export const ERR_VARINT_ENCODE_TOO_LARGE = "VARINT_ENCODE_TOO_LARGE"; /** - * A utility object for encoding and decoding varints. + * Creates a custom error object with a code property. + * @param {string} message The error message + * @param {string} code The error code + * @returns {Error} */ -const varint = { - /** - * Encodes an integer value into a varint byte buffer. - * @param {number} val - The integer value to encode. - * @returns {Buffer} - */ - encodeInt: (val) => { - // "VarInts are never longer than 5 bytes" - // https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Data_types#VarInt_and_VarLong - const buf = Buffer.alloc(5); - let written = 0; +function createVarIntError(message, code) { + const err = new Error(message); + err.code = code; + return err; +} - while (true) { - const byte = val & 0x7f; - val >>>= 7; +/** + * 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 {Error} if the value is too large to be encoded + */ +export function encodeVarInt(value) { + const buf = Buffer.alloc(5); + let written = 0; + let val = value; - if (val === 0) { - buf.writeUInt8(byte, written++); - break; - } + while (true) { + const byte = val & 0x7f; + val >>>= 7; - buf.writeUInt8(byte | 0x80, written++); + if (val === 0) { + buf.writeUInt8(byte, written++); + break; } - return buf.subarray(0, written); - }, + buf.writeUInt8(byte | 0x80, written++); - /** - * Encodes a string value into a UTF-8 byte buffer. - * @param {string} val - The string value to encode. - * @returns {Buffer} - */ - encodeString: (val) => { - return Buffer.from(val, "utf-8"); - }, + if (written >= 5 && val > 0) { + throw createVarIntError( + "Value too large for a 5-byte VarInt", + ERR_VARINT_ENCODE_TOO_LARGE + ); + } + } - /** - * Encodes an unsigned short value into a byte buffer. - * @param {number} val - The unsigned short value to encode. - * @returns {Buffer} - */ - encodeUShort: (val) => { - return Buffer.from([val >> 8, val & 0xff]); - }, + return buf.subarray(0, written); +} - /** - * Concatenates multiple byte buffers into a single byte buffer. - * @param {Buffer[]} chunks - An array of byte buffers to concatenate. - * @returns {Buffer} - */ - concat: (chunks) => { - let length = 0; +/** + * Encodes a string into a UTF-8 buffer. + * @param {string} value The string to encode + * @returns {Buffer} + */ +export function encodeString(value) { + return Buffer.from(value, "utf-8"); +} - for (const chunk of chunks) { - length += chunk.length; +/** + * Encodes an unsigned short (16-bit big-endian) into a 2-byte buffer. + * @param {number} value The number to encode + * @returns {Buffer} + */ +export function encodeUShort(value) { + const buf = Buffer.alloc(2); + buf.writeUInt16BE(value, 0); + return buf; +} + +/** + * Creates a Minecraft-style packet by concatenating chunks and prefixing the total length as a VarInt. + * @param {Buffer[]} chunks An array of buffers to include in the packet payload + * @returns {Buffer} The complete packet with its length prefix + */ +export function concatPackets(chunks) { + const payload = Buffer.concat(chunks); + const lengthPrefix = encodeVarInt(payload.length); + return Buffer.concat([lengthPrefix, payload]); +} + +/** + * Decodes a VarInt from a buffer. + * Returns the decoded value and the number of bytes it consumed. + * @param {Buffer} buffer The buffer to read from + * @param {number} [offset=0] The starting offset in the buffer + * @returns {{ value: number, bytesRead: number }} + * @throws {Error} if the buffer is too short or the VarInt is malformed + */ +export function decodeVarInt(buffer, offset = 0) { + if (offset >= buffer.length) { + throw createVarIntError( + "Buffer underflow: Cannot decode VarInt at or beyond buffer length.", + ERR_VARINT_BUFFER_UNDERFLOW + ); + } + + // Fast path for single-byte VarInts, which are very common. + const firstByte = buffer.readUInt8(offset); + if ((firstByte & 0x80) === 0) { + return { value: firstByte, bytesRead: 1 }; + } + + let val = firstByte & 0x7f; // Get the first 7 bits + let position = 7; // Bit position for the next byte's data + let bytesRead = 1; // We've read one byte so far + let currentOffset = offset + 1; // Start reading from the next + + // Max 4 more bytes (total 5 bytes for a VarInt) + for (let i = 0; i < 4; i++) { + if (currentOffset >= buffer.length) { + throw createVarIntError( + "Buffer underflow: Incomplete VarInt, expected more bytes.", + ERR_VARINT_BUFFER_UNDERFLOW + ); } - const buffer = [varint.encodeInt(length), ...chunks]; + const byte = buffer.readUInt8(currentOffset); + bytesRead++; + currentOffset++; - return Buffer.concat(buffer); - }, + val |= (byte & 0x7f) << position; + position += 7; - /** - * Decodes a varint integer value from a buffer. - * @param {Buffer} buffer - The byte buffer to decode from. - * @param {number} offset - The offset in the buffer to start decoding from. - * @returns {number} - */ - decodeInt: (buffer, offset) => { - // Fast path for single-byte varints - const firstByte = buffer.readUInt8(offset); - if (firstByte < 0x80) { - return firstByte; + if ((byte & 0x80) === 0) { + return { value: val, bytesRead: bytesRead }; } + } - let val = firstByte & 0x7f; - let position = 7; - - while (position < 32) { - const byte = buffer.readUInt8(++offset); - val |= (byte & 0x7f) << position; - - if ((byte & 0x80) === 0) { - return val; - } - - position += 7; - } - - throw new Error("VarInt is too big"); - }, - - /** - * Calculates how many bytes are needed to encode a number as a VarInt - * VarInts use a variable number of bytes to efficiently encode integers - * Each byte uses 7 bits for the value and 1 bit to indicate if more bytes follow - * VarInts are never longer than 5 bytes - * - * @param {number} val - The number to calculate the VarInt length for - * @returns {1|2|3|4|5} The number of bytes needed to encode the value - */ - decodeLength: (val) => { - // Using bit shifts to calculate power of 2 thresholds - // 1 << 7 = 2^7 = 128 - Numbers below this fit in 1 byte - // 1 << 14 = 2^14 = 16,384 - Numbers below this fit in 2 bytes - // 1 << 21 = 2^21 = 2,097,152 - Numbers below this fit in 3 bytes - // 1 << 28 = 2^28 = 268,435,456 - Numbers below this fit in 4 bytes - // Any larger number needs 5 bytes (maximum VarInt size) - - if (val < 1 << 7) return 1; - if (val < 1 << 14) return 2; - if (val < 1 << 21) return 3; - if (val < 1 << 28) return 4; - return 5; - }, -}; - -export default varint; + throw createVarIntError( + "VarInt is too big or malformed: 5 bytes read with continuation bit still set.", + ERR_VARINT_MALFORMED + ); +} diff --git a/test/java.test.js b/test/java.test.js index ba9aaff..6b46b1c 100644 --- a/test/java.test.js +++ b/test/java.test.js @@ -1,112 +1,133 @@ import net from "node:net"; +import dns from "node:dns/promises"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { pingJava } from "../lib/java.js"; -import varint from "../lib/varint.js"; +import * as varint from "../lib/varint.js"; vi.mock("node:net"); +vi.mock("node:dns/promises"); describe("pingJava", () => { let mockSocket; beforeEach(() => { + // Simulate no SRV record found. + dns.resolveSrv.mockResolvedValue([]); + const mockHandlers = {}; mockSocket = { write: vi.fn(), - destroy: vi.fn(), + // Make `destroy` emit 'error' if an error is passed. + destroy: vi.fn((err) => { + if (err) { + mockSocket.emit("error", err); + } + }), setNoDelay: vi.fn(), on: vi.fn((event, handler) => (mockHandlers[event] = handler)), emit: vi.fn((event, ...args) => mockHandlers[event]?.(...args)), }; - net.createConnection = vi.fn().mockReturnValue(mockSocket); + net.createConnection.mockReturnValue(mockSocket); vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); it("should ping a server and handle a chunked response", async () => { const host = "mc.hypixel.net"; - const options = { - port: 25565, - timeout: 5000, - protocolVersion: 765, - virtualHost: "mc.hypixel.net", - }; - + const options = { port: 25565 }; const pingPromise = pingJava(host, options); - mockSocket.emit("connect"); + // Allow the async SRV lookup to complete + await vi.runAllTicks(); + expect(dns.resolveSrv).toHaveBeenCalledWith(`_minecraft._tcp.${host}`); expect(net.createConnection).toHaveBeenCalledWith({ host, port: options.port, }); - expect(mockSocket.setNoDelay).toHaveBeenCalledWith(true); + + mockSocket.emit("connect"); expect(mockSocket.write).toHaveBeenCalledTimes(2); const mockResponse = { version: { name: "1.21", protocol: 765 }, players: { max: 20, online: 5, sample: [] }, description: "A Minecraft Server", - favicon: "data:image/png;base64,iVBORw0KGgo...", }; + const fullPacket = createMockJavaResponse(mockResponse); const chunk1 = fullPacket.subarray(0, 10); const chunk2 = fullPacket.subarray(10); + // Simulate receiving data in chunks mockSocket.emit("data", chunk1); mockSocket.emit("data", chunk2); const result = await pingPromise; expect(result).toEqual(mockResponse); - expect(mockSocket.destroy).toHaveBeenCalled(); }); describe("errors", () => { - it("should throw an error if host is not provided", () => { - expect(() => pingJava(null)).toThrow("Host argument is not provided"); + it("should throw an error if host is not provided", async () => { + await expect(pingJava(null)).rejects.toThrow("Host argument is required"); }); - it("should reject on socket timeout before data is received", async () => { + it("should reject on socket timeout", async () => { const pingPromise = pingJava("localhost", { timeout: 1000 }); + await vi.runAllTicks(); mockSocket.emit("connect"); - - // Advance time to trigger the timeout vi.advanceTimersByTime(1000); - await expect(pingPromise).rejects.toThrow("Socket timeout"); - expect(mockSocket.destroy).toHaveBeenCalled(); }); it("should reject on connection error", async () => { const pingPromise = pingJava("localhost"); - - // Simulate a connection refusal + await vi.runAllTicks(); mockSocket.emit("error", new Error("ECONNREFUSED")); - await expect(pingPromise).rejects.toThrow("ECONNREFUSED"); }); + it("should reject if the socket closes prematurely without a response", async () => { + const pingPromise = pingJava("localhost"); + + // Allow the initial async operations to complete + await vi.runAllTicks(); + + // Simulate the server accepting the connection and then immediately closing it + mockSocket.emit("connect"); + mockSocket.emit("close"); + + // The promise should reject with our specific 'close' handler message + await expect(pingPromise).rejects.toThrow( + "Socket closed unexpectedly without a response." + ); + }); + it("should only reject once, even if multiple errors occur", async () => { const pingPromise = pingJava("localhost"); - - // Fire two errors back-to-back + await vi.runAllTicks(); mockSocket.emit("error", new Error("First error")); - mockSocket.emit("error", new Error("Second error")); - + mockSocket.emit("error", new Error("Second error")); // Should be ignored await expect(pingPromise).rejects.toThrow("First error"); - expect(mockSocket.destroy).toHaveBeenCalledTimes(1); }); }); }); +/** + * Creates a mock Java status response packet according to the protocol. + * Structure: [Overall Length] [Packet ID] [JSON Length] [JSON String] + * @param {object} response The JSON response object + * @returns {Buffer} + */ function createMockJavaResponse(response) { const jsonString = JSON.stringify(response); - const jsonBuffer = Buffer.from(jsonString, "utf8"); - const responseLength = varint.encodeInt(jsonBuffer.length); - const packetId = varint.encodeInt(0); - const packetData = Buffer.concat([packetId, responseLength, jsonBuffer]); - const packetLength = varint.encodeInt(packetData.length); - return Buffer.concat([packetLength, packetData]); + const jsonBuffer = varint.encodeString(jsonString); + const jsonLength = varint.encodeVarInt(jsonBuffer.length); + const packetId = varint.encodeVarInt(0x00); + const payloadParts = [packetId, jsonLength, jsonBuffer]; + return varint.concatPackets(payloadParts); } diff --git a/test/varint.test.js b/test/varint.test.js index 27670a0..4b8e16e 100644 --- a/test/varint.test.js +++ b/test/varint.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import varint from "../lib/varint.js"; +import * as varint from "../lib/varint.js"; describe("varint.js", () => { it("should encode and decode integers symmetrically (round-trip)", () => { @@ -16,8 +16,8 @@ describe("varint.js", () => { ]; testValues.forEach((value) => { - const encoded = varint.encodeInt(value); - const decoded = varint.decodeInt(encoded, 0); + const encoded = varint.encodeVarInt(value); + const { value: decoded } = varint.decodeVarInt(encoded, 0); expect(decoded, `Value ${value} failed round-trip`).toBe(value); }); }); @@ -25,52 +25,37 @@ describe("varint.js", () => { it("should decode an integer from a non-zero offset", () => { // [255 (invalid varint), 128 (valid varint), 127 (valid varint)] const buffer = Buffer.from([0xff, 0x80, 0x01, 0x7f]); - expect(varint.decodeInt(buffer, 1)).toBe(128); + const { value: decoded } = varint.decodeVarInt(buffer, 1); + expect(decoded).toBe(128); }); it("should throw an error for a malformed varint that is too long", () => { const invalidBuffer = Buffer.from([0x80, 0x80, 0x80, 0x80, 0x80, 0x80]); - expect(() => varint.decodeInt(invalidBuffer, 0)).toThrow( - "VarInt is too big" + expect(() => varint.decodeVarInt(invalidBuffer, 0)).toThrow( + "VarInt is too big or malformed" ); }); - it("should correctly predict the encoded length of a varint", () => { - const boundaries = [0, 127, 128, 16383, 16384, 2097151, 2097152]; - boundaries.forEach((value) => { - const predictedLength = varint.decodeLength(value); - const actualLength = varint.encodeInt(value).length; - expect(predictedLength).toBe(actualLength); - }); - }); - it("should encode 16-bit unsigned shorts in big-endian format", () => { expect(varint.encodeUShort(0)).toEqual(Buffer.from([0x00, 0x00])); expect(varint.encodeUShort(256)).toEqual(Buffer.from([0x01, 0x00])); expect(varint.encodeUShort(65535)).toEqual(Buffer.from([0xff, 0xff])); }); - it("should correctly assemble and parse a Minecraft handshake packet", () => { - const protocolVersion = -1; - const virtualHost = "mc.example.com"; - const port = 25565; - - const payload = Buffer.concat([ - varint.encodeInt(0), - varint.encodeInt(protocolVersion), - varint.encodeInt(virtualHost.length), - varint.encodeString(virtualHost), - varint.encodeUShort(port), - varint.encodeInt(1), - ]); - - const finalPacket = varint.concat([payload]); - - const decodedPacketLength = varint.decodeInt(finalPacket, 0); + it("should correctly assemble a Minecraft packet with a length prefix", () => { + const payloadParts = [ + varint.encodeVarInt(0), // protocol + varint.encodeString("mc.example.com"), // host + varint.encodeUShort(25565), // port + ]; + const payload = Buffer.concat(payloadParts); + const finalPacket = varint.concatPackets(payloadParts); + const { value: decodedPacketLength, bytesRead } = varint.decodeVarInt( + finalPacket, + 0 + ); expect(decodedPacketLength).toBe(payload.length); - - const lengthOfPacketLength = varint.decodeLength(decodedPacketLength); - const decodedPayload = finalPacket.subarray(lengthOfPacketLength); + const decodedPayload = finalPacket.subarray(bytesRead); expect(decodedPayload).toEqual(payload); }); });