@deitylamb/mcping 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ import * as dns from "node:dns/promises";
2
+ import * as net from "node:net";
3
+ import * as dgram from "node:dgram";
4
+ import * as crypto from "node:crypto";
5
+ //#region src/target.ts
6
+ /**
7
+ * Resolves host and port, handling SRV records and lookups.
8
+ */
9
+ async function resolveTarget(targetStr, type) {
10
+ let host = targetStr;
11
+ let port = type === "bedrock" ? 19132 : 25565;
12
+ const parts = targetStr.split(":");
13
+ if (parts.length === 2 && !isNaN(parseInt(parts[1]))) {
14
+ host = parts[0];
15
+ port = parseInt(parts[1]);
16
+ const ip = await resolveIp(host);
17
+ return {
18
+ host,
19
+ port,
20
+ protocol: type === null ? "java" : type,
21
+ ip
22
+ };
23
+ }
24
+ if (type !== "bedrock") try {
25
+ const srv = await dns.resolveSrv(`_minecraft._tcp.${host}`);
26
+ if (srv && srv.length > 0) {
27
+ const srvHost = srv[0].name;
28
+ return {
29
+ host: srvHost,
30
+ port: srv[0].port,
31
+ protocol: "java",
32
+ ip: await resolveIp(srvHost)
33
+ };
34
+ }
35
+ } catch (e) {}
36
+ if (type === "bedrock" && parts.length === 1) try {
37
+ const srv = await dns.resolveSrv(`_minecraft._udp.${host}`);
38
+ if (srv && srv.length > 0) {
39
+ const srvHost = srv[0].name;
40
+ return {
41
+ host: srvHost,
42
+ port: srv[0].port,
43
+ protocol: "bedrock",
44
+ ip: await resolveIp(srvHost)
45
+ };
46
+ }
47
+ } catch (e) {}
48
+ const ip = await resolveIp(host);
49
+ return {
50
+ host,
51
+ port,
52
+ protocol: type || "java",
53
+ ip
54
+ };
55
+ }
56
+ async function resolveIp(host) {
57
+ try {
58
+ return (await dns.lookup(host)).address;
59
+ } catch (e) {
60
+ return host;
61
+ }
62
+ }
63
+ //#endregion
64
+ //#region src/varint.ts
65
+ /**
66
+ * VarInt encoding and decoding for Minecraft protocol.
67
+ * Based on the specification at https://wiki.vg/Protocol#VarInt_and_VarLong
68
+ */
69
+ function encodeVarInt(value) {
70
+ const bytes = [];
71
+ let temp = value >>> 0;
72
+ while (temp >= 128) {
73
+ bytes.push(temp & 127 | 128);
74
+ temp >>>= 7;
75
+ }
76
+ bytes.push(temp);
77
+ return Buffer.from(bytes);
78
+ }
79
+ function decodeVarInt(buffer, offset = 0) {
80
+ let value = 0;
81
+ let length = 0;
82
+ let currentByte;
83
+ while (true) {
84
+ currentByte = buffer.readUInt8(offset + length);
85
+ value |= (currentByte & 127) << length * 7;
86
+ length++;
87
+ if (length > 5) throw new Error("VarInt is too big");
88
+ if ((currentByte & 128) !== 128) break;
89
+ }
90
+ return {
91
+ value: value | 0,
92
+ length
93
+ };
94
+ }
95
+ function encodeString(str) {
96
+ const content = Buffer.from(str, "utf8");
97
+ return Buffer.concat([encodeVarInt(content.length), content]);
98
+ }
99
+ function decodeString(buffer, offset = 0) {
100
+ const { value: strLen, length: varIntLen } = decodeVarInt(buffer, offset);
101
+ return {
102
+ value: buffer.toString("utf8", offset + varIntLen, offset + varIntLen + strLen),
103
+ length: varIntLen + strLen
104
+ };
105
+ }
106
+ //#endregion
107
+ //#region src/java-ping.ts
108
+ async function pingJava(host, port, timeout) {
109
+ return new Promise((resolve, reject) => {
110
+ const client = new net.Socket();
111
+ let startTime;
112
+ let response;
113
+ const timeoutHandler = setTimeout(() => {
114
+ client.destroy();
115
+ reject(/* @__PURE__ */ new Error(`Connection timed out after ${timeout}ms`));
116
+ }, timeout);
117
+ client.connect(port, host, () => {
118
+ const protocolVersion = encodeVarInt(763);
119
+ const serverAddress = encodeString(host);
120
+ const serverPort = Buffer.alloc(2);
121
+ serverPort.writeUInt16BE(port);
122
+ const nextState = encodeVarInt(1);
123
+ const handshakePayload = Buffer.concat([
124
+ protocolVersion,
125
+ serverAddress,
126
+ serverPort,
127
+ nextState
128
+ ]);
129
+ const packetIdBuffer = encodeVarInt(0);
130
+ const handshake = Buffer.concat([
131
+ encodeVarInt(handshakePayload.length + packetIdBuffer.length),
132
+ packetIdBuffer,
133
+ handshakePayload
134
+ ]);
135
+ client.write(handshake);
136
+ const statusRequest = Buffer.concat([encodeVarInt(1), encodeVarInt(0)]);
137
+ client.write(statusRequest);
138
+ startTime = Date.now();
139
+ });
140
+ let data = Buffer.alloc(0);
141
+ client.on("data", (chunk) => {
142
+ data = Buffer.concat([data, chunk]);
143
+ while (data.length > 0) try {
144
+ const { value: packetLen, length: varIntLen } = decodeVarInt(data, 0);
145
+ if (data.length < packetLen + varIntLen) return;
146
+ const packet = data.subarray(varIntLen, varIntLen + packetLen);
147
+ data = data.subarray(varIntLen + packetLen);
148
+ const { value: packetId, length: idLen } = decodeVarInt(packet, 0);
149
+ if (packetId === 0) {
150
+ const { value: jsonStr } = decodeString(packet, idLen);
151
+ response = JSON.parse(jsonStr);
152
+ const pingPayload = Buffer.alloc(8);
153
+ pingPayload.writeBigInt64BE(BigInt(startTime));
154
+ const pingPacket = Buffer.concat([
155
+ encodeVarInt(9),
156
+ encodeVarInt(1),
157
+ pingPayload
158
+ ]);
159
+ client.write(pingPacket);
160
+ } else if (packetId === 1) {
161
+ const latency = Date.now() - startTime;
162
+ clearTimeout(timeoutHandler);
163
+ client.destroy();
164
+ resolve({
165
+ response,
166
+ latency
167
+ });
168
+ return;
169
+ }
170
+ } catch (e) {
171
+ return;
172
+ }
173
+ });
174
+ client.on("error", (err) => {
175
+ clearTimeout(timeoutHandler);
176
+ reject(err);
177
+ });
178
+ });
179
+ }
180
+ //#endregion
181
+ //#region src/bedrock-ping.ts
182
+ const RAKNET_MAGIC = Buffer.from("00ffff00fefefefefdfdfdfd12345678", "hex");
183
+ async function pingBedrock(host, port, timeout) {
184
+ return new Promise((resolve, reject) => {
185
+ const client = dgram.createSocket("udp4");
186
+ const timeoutHandler = setTimeout(() => {
187
+ client.close();
188
+ reject(/* @__PURE__ */ new Error(`Bedrock ping timed out after ${timeout}ms`));
189
+ }, timeout);
190
+ const startTime = BigInt(Date.now());
191
+ const clientGUID = crypto.randomBytes(8);
192
+ const packet = Buffer.alloc(33);
193
+ packet.writeUInt8(1, 0);
194
+ packet.writeBigInt64BE(startTime, 1);
195
+ RAKNET_MAGIC.copy(packet, 9);
196
+ clientGUID.copy(packet, 25);
197
+ client.send(packet, port, host);
198
+ client.on("message", (msg) => {
199
+ if (msg.readUInt8(0) === 28) {
200
+ clearTimeout(timeoutHandler);
201
+ client.close();
202
+ msg.readBigInt64BE(1);
203
+ msg.readBigInt64BE(9);
204
+ if (!msg.slice(17, 33).equals(RAKNET_MAGIC)) {
205
+ reject(/* @__PURE__ */ new Error("Invalid RakNet magic"));
206
+ return;
207
+ }
208
+ const stringLength = msg.readUInt16BE(33);
209
+ const parts = msg.toString("utf8", 35, 35 + stringLength).split(";");
210
+ resolve({
211
+ edition: parts[0] || "MCPE",
212
+ motdLine1: parts[1] || "",
213
+ protocolVersion: parseInt(parts[2]) || 0,
214
+ versionName: parts[3] || "",
215
+ playerCount: parseInt(parts[4]) || 0,
216
+ maxPlayerCount: parseInt(parts[5]) || 0,
217
+ serverUniqueId: parts[6] || "",
218
+ motdLine2: parts[7] || "",
219
+ gameMode: parts[8] || "",
220
+ nintendoLimited: parseInt(parts[9]) || 0,
221
+ ipv4Port: parts[10] ? parseInt(parts[10]) : null,
222
+ ipv6Port: parts[11] ? parseInt(parts[11]) : null
223
+ });
224
+ }
225
+ });
226
+ client.on("error", (err) => {
227
+ clearTimeout(timeoutHandler);
228
+ client.close();
229
+ reject(err);
230
+ });
231
+ });
232
+ }
233
+ //#endregion
234
+ //#region src/chat.ts
235
+ function parseChat(chat) {
236
+ if (typeof chat === "string") return chat;
237
+ if (!chat || typeof chat !== "object") return "";
238
+ let text = "";
239
+ const colorMap = {
240
+ black: "0",
241
+ dark_blue: "1",
242
+ dark_green: "2",
243
+ dark_aqua: "3",
244
+ dark_red: "4",
245
+ dark_purple: "5",
246
+ gold: "6",
247
+ gray: "7",
248
+ dark_gray: "8",
249
+ blue: "9",
250
+ green: "a",
251
+ aqua: "b",
252
+ red: "c",
253
+ light_purple: "d",
254
+ yellow: "e",
255
+ white: "f"
256
+ };
257
+ if (chat.color && colorMap[chat.color]) text += `§${colorMap[chat.color]}`;
258
+ if (chat.bold) text += "§l";
259
+ if (chat.italic) text += "§o";
260
+ if (chat.underlined) text += "§n";
261
+ if (chat.strikethrough) text += "§m";
262
+ if (chat.obfuscated) text += "§k";
263
+ text += chat.text || "";
264
+ if (chat.extra && Array.isArray(chat.extra)) for (const part of chat.extra) text += parseChat(part);
265
+ return text;
266
+ }
267
+ //#endregion
268
+ //#region src/cache.ts
269
+ const cache = /* @__PURE__ */ new Map();
270
+ function getCache(target, options) {
271
+ const entry = cache.get(target);
272
+ if (!entry) return null;
273
+ if (!(Date.now() - entry.timestamp > options.ttl) || options.strategy === "swr") return {
274
+ ...entry.response,
275
+ cached: true
276
+ };
277
+ return null;
278
+ }
279
+ function setCache(target, response) {
280
+ if (response.cached) return;
281
+ cache.set(target, {
282
+ response,
283
+ timestamp: Date.now()
284
+ });
285
+ }
286
+ function isExpired(target, options) {
287
+ const entry = cache.get(target);
288
+ if (!entry) return true;
289
+ return Date.now() - entry.timestamp > options.ttl;
290
+ }
291
+ //#endregion
292
+ //#region src/index.ts
293
+ async function ping(target, options) {
294
+ const timeout = options?.timeout || 5e3;
295
+ const type = options?.type || null;
296
+ const cacheOptions = options?.cache;
297
+ if (cacheOptions) {
298
+ const cached = getCache(target, cacheOptions);
299
+ if (cached) {
300
+ if (cacheOptions.strategy === "swr" && isExpired(target, cacheOptions)) pingServer(target, timeout, type).then((refreshed) => setCache(target, refreshed)).catch(() => {});
301
+ return cached;
302
+ }
303
+ }
304
+ const result = await pingServer(target, timeout, type);
305
+ if (cacheOptions) setCache(target, result);
306
+ return result;
307
+ }
308
+ async function pingServer(target, timeout, type) {
309
+ const resolved = await resolveTarget(target, type);
310
+ let javaError;
311
+ if (type === "java" || type === null) try {
312
+ const { response, latency } = await pingJava(resolved.host, resolved.port, timeout);
313
+ return {
314
+ type: "java",
315
+ version: {
316
+ name: response.version.name,
317
+ protocol: response.version.protocol
318
+ },
319
+ players: {
320
+ online: response.players.online,
321
+ max: response.players.max
322
+ },
323
+ motd: parseChat(response.description),
324
+ latency,
325
+ target: {
326
+ host: resolved.host,
327
+ port: resolved.port,
328
+ ip: resolved.ip
329
+ },
330
+ raw: response
331
+ };
332
+ } catch (e) {
333
+ javaError = e;
334
+ if (type === "java") throw e;
335
+ }
336
+ try {
337
+ const bedrockResolved = type === "bedrock" ? resolved : await resolveTarget(target, "bedrock");
338
+ const response = await pingBedrock(bedrockResolved.host, bedrockResolved.port, timeout);
339
+ return {
340
+ type: "bedrock",
341
+ version: {
342
+ name: response.versionName,
343
+ protocol: response.protocolVersion
344
+ },
345
+ players: {
346
+ online: response.playerCount,
347
+ max: response.maxPlayerCount
348
+ },
349
+ motd: response.motdLine2 ? `${response.motdLine1}\n${response.motdLine2}` : response.motdLine1,
350
+ latency: 0,
351
+ target: {
352
+ host: bedrockResolved.host,
353
+ port: bedrockResolved.port,
354
+ ip: bedrockResolved.ip
355
+ },
356
+ raw: response
357
+ };
358
+ } catch (bedrockErr) {
359
+ if (type === null && javaError) throw new Error(`Both Java and Bedrock pings failed. Java: ${javaError.message}. Bedrock: ${bedrockErr.message}`);
360
+ throw bedrockErr;
361
+ }
362
+ }
363
+ //#endregion
364
+ export { ping as t };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@deitylamb/mcping",
3
+ "version": "1.1.0",
4
+ "description": "Minecraft server ping library",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.cts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.cjs",
13
+ "types": "./dist/index.d.cts"
14
+ }
15
+ },
16
+ "bin": {
17
+ "mcping": "./dist/cli.mjs"
18
+ },
19
+ "scripts": {
20
+ "dev": "tsdown src/index.ts --watch",
21
+ "build": "tsdown src/index.ts src/cli.ts --format cjs,esm --dts",
22
+ "test": "npm run test:unit && npm run test:e2e",
23
+ "test:unit": "vitest run test/unit",
24
+ "test:e2e": "vitest run test/e2e",
25
+ "format": "prettier --write .",
26
+ "format:check": "prettier --check ."
27
+ },
28
+ "keywords": [
29
+ "minecraft",
30
+ "ping",
31
+ "server",
32
+ "java",
33
+ "bedrock"
34
+ ],
35
+ "author": "",
36
+ "license": "MIT",
37
+ "devDependencies": {
38
+ "@types/node": "^25.5.2",
39
+ "prettier": "^3.8.1",
40
+ "tsdown": "^0.21.7",
41
+ "typescript": "^6.0.2",
42
+ "vitest": "^4.1.2"
43
+ }
44
+ }
package/src/ansi.ts ADDED
@@ -0,0 +1,50 @@
1
+ const mcColorMap: Record<string, string> = {
2
+ "0": "\x1b[30m", // black
3
+ "1": "\x1b[34m", // dark_blue
4
+ "2": "\x1b[32m", // dark_green
5
+ "3": "\x1b[36m", // dark_aqua
6
+ "4": "\x1b[31m", // dark_red
7
+ "5": "\x1b[35m", // dark_purple
8
+ "6": "\x1b[33m", // gold
9
+ "7": "\x1b[37m", // gray
10
+ "8": "\x1b[90m", // dark_gray
11
+ "9": "\x1b[94m", // blue
12
+ a: "\x1b[92m", // green
13
+ b: "\x1b[96m", // aqua
14
+ c: "\x1b[91m", // red
15
+ d: "\x1b[95m", // light_purple
16
+ e: "\x1b[93m", // yellow
17
+ f: "\x1b[97m", // white
18
+ l: "\x1b[1m", // bold
19
+ m: "\x1b[9m", // strikethrough
20
+ n: "\x1b[4m", // underline
21
+ o: "\x1b[3m", // italic
22
+ r: "\x1b[0m", // reset
23
+ };
24
+
25
+ /**
26
+ * Converts Minecraft color codes (§) to ANSI terminal colors.
27
+ */
28
+ export function mcToAnsi(text: string): string {
29
+ const reset = "\x1b[0m";
30
+ let result = "";
31
+ const parts = text.split("§");
32
+
33
+ result += parts[0];
34
+
35
+ for (let i = 1; i < parts.length; i++) {
36
+ const part = parts[i];
37
+ if (part.length === 0) continue;
38
+
39
+ const code = part[0].toLowerCase();
40
+ const ansi = mcColorMap[code];
41
+
42
+ if (ansi) {
43
+ result += ansi + part.substring(1);
44
+ } else {
45
+ result += "§" + part;
46
+ }
47
+ }
48
+
49
+ return result + reset;
50
+ }
@@ -0,0 +1,88 @@
1
+ import * as dgram from "node:dgram";
2
+ import * as crypto from "node:crypto";
3
+
4
+ export interface BedrockRawResponse {
5
+ edition: string;
6
+ motdLine1: string;
7
+ motdLine2: string;
8
+ protocolVersion: number;
9
+ versionName: string;
10
+ playerCount: number;
11
+ maxPlayerCount: number;
12
+ serverUniqueId: string;
13
+ gameMode: string;
14
+ nintendoLimited: number;
15
+ ipv4Port: number | null;
16
+ ipv6Port: number | null;
17
+ }
18
+
19
+ const RAKNET_MAGIC = Buffer.from("00ffff00fefefefefdfdfdfd12345678", "hex");
20
+
21
+ export async function pingBedrock(
22
+ host: string,
23
+ port: number,
24
+ timeout: number,
25
+ ): Promise<BedrockRawResponse> {
26
+ return new Promise((resolve, reject) => {
27
+ const client = dgram.createSocket("udp4");
28
+ const timeoutHandler = setTimeout(() => {
29
+ client.close();
30
+ reject(new Error(`Bedrock ping timed out after ${timeout}ms`));
31
+ }, timeout);
32
+
33
+ const startTime = BigInt(Date.now());
34
+ const clientGUID = crypto.randomBytes(8);
35
+
36
+ // Unconnected Ping (0x01)
37
+ const packet = Buffer.alloc(1 + 8 + 16 + 8);
38
+ packet.writeUInt8(0x01, 0); // Packet ID
39
+ packet.writeBigInt64BE(startTime, 1); // Time
40
+ RAKNET_MAGIC.copy(packet, 9); // Magic
41
+ clientGUID.copy(packet, 25); // Client GUID
42
+
43
+ client.send(packet, port, host);
44
+
45
+ client.on("message", (msg) => {
46
+ if (msg.readUInt8(0) === 0x1c) {
47
+ // Unconnected Pong (0x1C)
48
+ clearTimeout(timeoutHandler);
49
+ client.close();
50
+
51
+ const time = msg.readBigInt64BE(1);
52
+ const serverGUID = msg.readBigInt64BE(9);
53
+ const magic = msg.slice(17, 33);
54
+
55
+ if (!magic.equals(RAKNET_MAGIC)) {
56
+ reject(new Error("Invalid RakNet magic"));
57
+ return;
58
+ }
59
+
60
+ const stringLength = msg.readUInt16BE(33);
61
+ const body = msg.toString("utf8", 35, 35 + stringLength);
62
+
63
+ const parts = body.split(";");
64
+ const response: BedrockRawResponse = {
65
+ edition: parts[0] || "MCPE",
66
+ motdLine1: parts[1] || "",
67
+ protocolVersion: parseInt(parts[2]) || 0,
68
+ versionName: parts[3] || "",
69
+ playerCount: parseInt(parts[4]) || 0,
70
+ maxPlayerCount: parseInt(parts[5]) || 0,
71
+ serverUniqueId: parts[6] || "",
72
+ motdLine2: parts[7] || "",
73
+ gameMode: parts[8] || "",
74
+ nintendoLimited: parseInt(parts[9]) || 0,
75
+ ipv4Port: parts[10] ? parseInt(parts[10]) : null,
76
+ ipv6Port: parts[11] ? parseInt(parts[11]) : null,
77
+ };
78
+ resolve(response);
79
+ }
80
+ });
81
+
82
+ client.on("error", (err) => {
83
+ clearTimeout(timeoutHandler);
84
+ client.close();
85
+ reject(err);
86
+ });
87
+ });
88
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { PingResponse } from "./index.js";
2
+
3
+ export interface CacheOptions {
4
+ ttl: number;
5
+ strategy: "lazy" | "swr";
6
+ }
7
+
8
+ interface CacheEntry {
9
+ response: PingResponse;
10
+ timestamp: number;
11
+ }
12
+
13
+ const cache = new Map<string, CacheEntry>();
14
+
15
+ export function getCache(
16
+ target: string,
17
+ options: CacheOptions,
18
+ ): PingResponse | null {
19
+ const entry = cache.get(target);
20
+ if (!entry) return null;
21
+
22
+ const age = Date.now() - entry.timestamp;
23
+ const isExpired = age > options.ttl;
24
+
25
+ if (!isExpired || options.strategy === "swr") {
26
+ return { ...entry.response, cached: true };
27
+ }
28
+
29
+ return null;
30
+ }
31
+
32
+ export function setCache(target: string, response: PingResponse): void {
33
+ // Only cache successful, non-cached results
34
+ if (response.cached) return;
35
+ cache.set(target, { response, timestamp: Date.now() });
36
+ }
37
+
38
+ export function isExpired(target: string, options: CacheOptions): boolean {
39
+ const entry = cache.get(target);
40
+ if (!entry) return true;
41
+ return Date.now() - entry.timestamp > options.ttl;
42
+ }
package/src/chat.ts ADDED
@@ -0,0 +1,45 @@
1
+ export function parseChat(chat: any): string {
2
+ if (typeof chat === "string") return chat;
3
+ if (!chat || typeof chat !== "object") return "";
4
+
5
+ let text = "";
6
+
7
+ // Minecraft legacy color code mapping in JSON
8
+ const colorMap: Record<string, string> = {
9
+ black: "0",
10
+ dark_blue: "1",
11
+ dark_green: "2",
12
+ dark_aqua: "3",
13
+ dark_red: "4",
14
+ dark_purple: "5",
15
+ gold: "6",
16
+ gray: "7",
17
+ dark_gray: "8",
18
+ blue: "9",
19
+ green: "a",
20
+ aqua: "b",
21
+ red: "c",
22
+ light_purple: "d",
23
+ yellow: "e",
24
+ white: "f",
25
+ };
26
+
27
+ if (chat.color && colorMap[chat.color]) {
28
+ text += `§${colorMap[chat.color]}`;
29
+ }
30
+ if (chat.bold) text += "§l";
31
+ if (chat.italic) text += "§o";
32
+ if (chat.underlined) text += "§n";
33
+ if (chat.strikethrough) text += "§m";
34
+ if (chat.obfuscated) text += "§k";
35
+
36
+ text += chat.text || "";
37
+
38
+ if (chat.extra && Array.isArray(chat.extra)) {
39
+ for (const part of chat.extra) {
40
+ text += parseChat(part);
41
+ }
42
+ }
43
+
44
+ return text;
45
+ }