@rlqd/minecraft-server-util
Version:
A Node.js library for Minecraft servers that can retrieve status, perform queries, and RCON into servers.
198 lines (159 loc) • 6.16 kB
text/typescript
import assert from 'assert';
import { clean, format, parse, toHTML } from '@rlqd/minecraft-motd-util';
import UDPClient from './structure/UDPClient';
import { QueryOptions } from './types/QueryOptions';
import { resolveSRV } from './util/srvRecord';
const validKeys: Buffer[] = [
'gametype',
'game_id',
'version',
'plugins',
'map',
'numplayers',
'maxplayers',
'hostport',
'hostip'
].map(s => Buffer.from(s, 'ascii'));
export interface FullQueryResponse {
motd: {
raw: string,
clean: string,
html: string
},
version: string,
software: string,
plugins: string[],
map: string,
players: {
online: number,
max: number,
list: string[]
},
hostIP: string,
hostPort: number
}
export function queryFull(host: string, port = 25565, options?: QueryOptions): Promise<FullQueryResponse> {
host = host.trim();
assert(typeof host === 'string', `Expected 'host' to be a 'string', got '${typeof host}'`);
assert(host.length > 0, `Expected 'host' to have a length greater than 0, got ${host.length}`);
assert(typeof port === 'number', `Expected 'port' to be a 'number', got '${typeof port}'`);
assert(Number.isInteger(port), `Expected 'port' to be an integer, got '${port}'`);
assert(port >= 0, `Expected 'port' to be greater than or equal to 0, got '${port}'`);
assert(port <= 65535, `Expected 'port' to be less than or equal to 65535, got '${port}'`);
assert(typeof options === 'object' || typeof options === 'undefined', `Expected 'options' to be an 'object' or 'undefined', got '${typeof options}'`);
if (typeof options === 'object') {
assert(typeof options.enableSRV === 'boolean' || typeof options.enableSRV === 'undefined', `Expected 'options.enableSRV' to be a 'boolean' or 'undefined', got '${typeof options.enableSRV}'`);
assert(typeof options.sessionID === 'number' || typeof options.sessionID === 'undefined', `Expected 'options.sessionID' to be a 'number' or 'undefined', got '${typeof options.sessionID}'`);
assert(typeof options.timeout === 'number' || typeof options.timeout === 'undefined', `Expected 'options.timeout' to be a 'number' or 'undefined', got '${typeof options.timeout}'`);
if (typeof options.timeout === 'number') {
assert(Number.isInteger(options.timeout), `Expected 'options.timeout' to be an integer, got '${options.timeout}'`);
assert(options.timeout >= 0, `Expected 'options.timeout' to be greater than or equal to 0, got '${options.timeout}'`);
}
}
const sessionID = (options?.sessionID ?? 1) & 0x0F0F0F0F;
return new Promise(async (resolve, reject) => {
const socket = new UDPClient(host, port);
const timeout = setTimeout(() => {
socket?.close();
reject(new Error('Server is offline or unreachable'));
}, options?.timeout ?? 1000 * 5);
try {
let srvRecord = null;
if (typeof options === 'undefined' || typeof options.enableSRV === 'undefined' || options.enableSRV) {
srvRecord = await resolveSRV(host, 'udp');
if (srvRecord) {
host = srvRecord.host;
port = srvRecord.port;
}
}
// Request packet
// https://minecraft.wiki/w/Java_Edition_protocol/Query#Request
{
socket.writeUInt16BE(0xFEFD);
socket.writeByte(0x09);
socket.writeInt32BE(sessionID);
await socket.flush(false);
}
let challengeToken;
// Response packet
// https://minecraft.wiki/w/Java_Edition_protocol/Query#Response
{
const packetType = await socket.readByte();
if (packetType !== 0x09) throw new Error('Expected server to send packet type 0x09, received ' + packetType);
const serverSessionID = await socket.readInt32BE();
if (sessionID !== serverSessionID) throw new Error('Server session ID mismatch, expected ' + sessionID + ', received ' + serverSessionID);
challengeToken = parseInt(await socket.readStringNT());
if (isNaN(challengeToken)) throw new Error('Server sent an invalid challenge token');
}
// Full stat request packet
// https://minecraft.wiki/w/Java_Edition_protocol/Query#Request_3
{
socket.writeUInt16BE(0xFEFD);
socket.writeByte(0x00);
socket.writeInt32BE(sessionID);
socket.writeInt32BE(challengeToken);
socket.writeBytes(Uint8Array.from([0x00, 0x00, 0x00, 0x00]));
await socket.flush(false);
}
// Full stat response packet
// https://minecraft.wiki/w/Java_Edition_protocol/Query#Response_3
{
const packetType = await socket.readByte();
if (packetType !== 0x00) throw new Error('Expected server to send packet type 0x00, received ' + packetType);
const serverSessionID = await socket.readInt32BE();
if (sessionID !== serverSessionID) throw new Error('Server session ID mismatch, expected ' + sessionID + ', received ' + serverSessionID);
await socket.readBytes(11);
const data: Record<string, string> = {};
const players: string[] = [];
// eslint-disable-next-line no-constant-condition
while (true) {
const key = await socket.readStringNT();
if (key.length < 1) break;
let value;
if (key === 'hostname') {
value = await socket.readStringNTFollowedBy(validKeys);
} else {
value = await socket.readStringNT();
}
data[key] = value;
}
await socket.readBytes(10);
// eslint-disable-next-line no-constant-condition
while (true) {
const username = await socket.readStringNT();
if (username.length < 1) break;
players.push(username);
}
const motd = parse(data.hostname);
const plugins = data.plugins.split(/(?::|;) */g);
socket.close();
if (socket.hasRemainingData()) {
throw new Error('Server sent more data than expected');
}
clearTimeout(timeout);
resolve({
motd: {
raw: format(motd),
clean: clean(motd),
html: toHTML(motd)
},
version: data.version,
software: plugins[0],
plugins: plugins.slice(1),
map: data.map,
players: {
online: parseInt(data.numplayers),
max: parseInt(data.maxplayers),
list: players
},
hostIP: data.hostip,
hostPort: parseInt(data.hostport)
});
}
} catch (e) {
clearTimeout(timeout);
socket?.close();
reject(e);
}
});
}