refactor: introduce typescript for static type checking

We use JSDoc for documentation, but these annotations
were not being validated. This meant that type information
could become outdated or incorrect without any warning.

This commit introduces the TypeScript compiler (`tsc`) as a static
analysis tool to leverage our existing JSDoc comments.

To support this, JSDoc annotations across the codebase have been
improved for accuracy. Additionally, the `varint` module now uses a
custom `VarIntError` class for better type inference and error handling.
A new `typecheck` script has been added to `package.json` to run this
validation.
This commit is contained in:
Timofey Gelazoniya 2025-06-19 02:57:11 +03:00
parent 7322034aba
commit a1b999ca4e
Signed by: zeldon
GPG Key ID: D99707D1FF69FAB0
6 changed files with 168 additions and 80 deletions

View File

@ -18,44 +18,50 @@ 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}
* 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 | 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
* @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
* @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
* @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} timestamp - The current time delta since the script started
* 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/RakNet#Unconnected_Ping}
*/
const createUnconnectedPingFrame = (timestamp) => {
const buffer = Buffer.alloc(33);
@ -68,8 +74,9 @@ const createUnconnectedPingFrame = (timestamp) => {
/**
* 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
* @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 parseMotd = (motdString) => {
const parts = motdString.split(";");
@ -122,8 +129,8 @@ const parseMotd = (motdString) => {
/**
* Transforms the raw MOTD object into a user-friendly, nested structure.
* @param {BedrockMotd} motd - The parsed MOTD object
* @returns {BedrockPingResponse}
* @param {BedrockMotd} motd - The parsed MOTD object.
* @returns {BedrockPingResponse} The final, user-facing response object.
*/
const transformMotd = (motd) => {
return {
@ -151,9 +158,9 @@ const transformMotd = (motd) => {
/**
* 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
* @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) {
@ -189,11 +196,9 @@ const parseUnconnectedPong = (pongPacket) => {
/**
* 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
* @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 const pingBedrock = (host, options = {}) => {
if (!host) {

View File

@ -14,15 +14,22 @@ const debug = createDebug("mineping:java");
/**
* Represents the structured and user-friendly response from a server ping.
* The fields and their optionality are based on the official protocol documentation.
* 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 }> } | 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}
* @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.
*/
/**
* @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.
*/
/**
@ -106,6 +113,7 @@ function processResponse(buffer) {
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.
@ -113,17 +121,16 @@ function processResponse(buffer) {
// For malformed VarInts or JSON, throw the error to reject the promise.
throw err;
}
throw err;
}
}
/**
* Pings a Minecraft Java Edition server.
* 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 {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<JavaPingResponse>} A promise that resolves with the server's 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() === "") {
@ -150,10 +157,17 @@ export async function pingJava(host, options = {}) {
}
} 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)) {
debug("SRV lookup for %s failed (%s), using fallback", host, err.code);
// For other errors we should re-throw.
// does not have an SRV record, so we ignore them and proceed
if (
err instanceof Error &&
"code" in err &&
(err.code === "ENODATA" || err.code === "ENOTFOUND")
) {
// Non fatal DNS error, log it and continue
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;
}
}

View File

@ -6,16 +6,16 @@ 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 {
/**
* Creates a custom error object with a code property.
* @param {string} message The error message
* @param {string} code The error code
* @returns {Error}
* @param {string} message The error message.
* @param {string} code The error code.
*/
function createVarIntError(message, code) {
const err = new Error(message);
err.code = code;
return err;
constructor(message, code) {
super(message);
this.name = "VarIntError";
this.code = code;
}
}
/**
@ -23,7 +23,7 @@ function createVarIntError(message, code) {
* 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
* @throws {VarIntError} if the value is too large to be encoded
*/
export function encodeVarInt(value) {
const buf = Buffer.alloc(5);
@ -42,7 +42,7 @@ export function encodeVarInt(value) {
buf.writeUInt8(byte | 0x80, written++);
if (written >= 5 && val > 0) {
throw createVarIntError(
throw new VarIntError(
"Value too large for a 5-byte VarInt",
ERR_VARINT_ENCODE_TOO_LARGE
);
@ -89,11 +89,11 @@ export function concatPackets(chunks) {
* @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
* @throws {VarIntError} if the buffer is too short or the VarInt is malformed
*/
export function decodeVarInt(buffer, offset = 0) {
if (offset >= buffer.length) {
throw createVarIntError(
throw new VarIntError(
"Buffer underflow: Cannot decode VarInt at or beyond buffer length.",
ERR_VARINT_BUFFER_UNDERFLOW
);
@ -113,7 +113,7 @@ export function decodeVarInt(buffer, offset = 0) {
// Max 4 more bytes (total 5 bytes for a VarInt)
for (let i = 0; i < 4; i++) {
if (currentOffset >= buffer.length) {
throw createVarIntError(
throw new VarIntError(
"Buffer underflow: Incomplete VarInt, expected more bytes.",
ERR_VARINT_BUFFER_UNDERFLOW
);
@ -131,7 +131,7 @@ export function decodeVarInt(buffer, offset = 0) {
}
}
throw createVarIntError(
throw new VarIntError(
"VarInt is too big or malformed: 5 bytes read with continuation bit still set.",
ERR_VARINT_MALFORMED
);

55
package-lock.json generated
View File

@ -1,17 +1,20 @@
{
"name": "@minescope/mineping",
"version": "1.7.0-beta.0",
"version": "1.7.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@minescope/mineping",
"version": "1.7.0-beta.0",
"version": "1.7.0-beta.1",
"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"
},
"engines": {
@ -740,6 +743,16 @@
"@types/deep-eql": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -754,6 +767,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.8.0"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz",
@ -1308,6 +1338,27 @@
"node": ">=14.0.0"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@ -7,7 +7,8 @@
"types": "types/index.d.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"repository": {
"type": "git",
@ -27,6 +28,9 @@
"debug": "^4.4.1"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/node": "^24.0.3",
"typescript": "^5.8.3",
"vitest": "^3.2.3"
}
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"moduleResolution": "node",
"target": "ES2022",
"lib": ["ES2022"],
"esModuleInterop": true
},
"include": ["lib", "index.js"],
"exclude": ["node_modules"]
}