minecraft-toolkit 0.1.0 → 0.1.2

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,144 @@
1
+ import { MOJANG_PROFILE_BASE, SESSION_PROFILE_BASE, NAME_HISTORY_BASE } from "../../constants.js";
2
+ import { MinecraftToolkitError } from "../../errors.js";
3
+ import { fetchJson } from "../../utils/http/index.js";
4
+ import { normalizeUsername } from "../../utils/validation.js";
5
+ import { decodeTexturePayload, getSkinURL, getCapeURL, extractTextureHash } from "../textures.js";
6
+
7
+ export async function fetchPlayerProfile(username) {
8
+ const normalizedUsername = normalizeUsername(username);
9
+ const identity = await fetchJson(
10
+ `${MOJANG_PROFILE_BASE}/${encodeURIComponent(normalizedUsername)}`,
11
+ {
12
+ notFoundMessage: "Player not found",
13
+ },
14
+ );
15
+
16
+ const sessionProfile = await fetchJson(`${SESSION_PROFILE_BASE}/${identity.id}`);
17
+ const texturePayload = decodeTexturePayload(sessionProfile.properties);
18
+
19
+ return {
20
+ id: identity.id,
21
+ name: identity.name,
22
+ profile: sessionProfile,
23
+ textures: texturePayload?.textures ?? {},
24
+ skin: texturePayload?.textures?.SKIN ?? null,
25
+ cape: texturePayload?.textures?.CAPE ?? null,
26
+ };
27
+ }
28
+
29
+ export async function playerExists(username) {
30
+ const normalizedUsername = normalizeUsername(username);
31
+ try {
32
+ await fetchJson(`${MOJANG_PROFILE_BASE}/${encodeURIComponent(normalizedUsername)}`);
33
+ return true;
34
+ } catch (error) {
35
+ if (error instanceof MinecraftToolkitError && error.statusCode === 404) {
36
+ return false;
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ export async function fetchPlayerSummary(username) {
43
+ const profile = await fetchPlayerProfile(username);
44
+ return {
45
+ id: profile.id,
46
+ name: profile.name,
47
+ skinUrl: getSkinURL(profile),
48
+ capeUrl: getCapeURL(profile),
49
+ };
50
+ }
51
+
52
+ export function hasSkinChanged(profileA, profileB) {
53
+ const hashA = extractTextureHash(getSkinURL(profileA));
54
+ const hashB = extractTextureHash(getSkinURL(profileB));
55
+ return hashA !== hashB;
56
+ }
57
+
58
+ export async function fetchPlayerSkin(username) {
59
+ const profile = await fetchPlayerProfile(username);
60
+ return {
61
+ id: profile.id,
62
+ name: profile.name,
63
+ skin: profile.skin,
64
+ cape: profile.cape,
65
+ };
66
+ }
67
+
68
+ export async function fetchPlayerUUID(username) {
69
+ const profile = await fetchPlayerProfile(username);
70
+ return {
71
+ id: profile.id,
72
+ name: profile.name,
73
+ };
74
+ }
75
+
76
+ export async function fetchUsernameByUUID(uuid) {
77
+ const response = await fetchJson(`${SESSION_PROFILE_BASE}/${uuid}`, {
78
+ notFoundMessage: "UUID not found",
79
+ });
80
+ return {
81
+ id: response.id,
82
+ name: response.name,
83
+ };
84
+ }
85
+
86
+ export async function fetchNameHistory(uuid) {
87
+ const entries = await fetchJson(`${NAME_HISTORY_BASE}/${uuid}/names`, {
88
+ notFoundMessage: "UUID not found",
89
+ });
90
+ return Array.isArray(entries)
91
+ ? entries.map((entry) => ({
92
+ name: entry.name,
93
+ changedAt: entry.changedToAt ? new Date(entry.changedToAt) : null,
94
+ }))
95
+ : [];
96
+ }
97
+
98
+ export async function fetchPlayers(usernames, options = {}) {
99
+ const { delayMs = 100, signal } = options;
100
+ if (!Array.isArray(usernames) || usernames.length === 0) {
101
+ return [];
102
+ }
103
+
104
+ const deduped = Array.from(new Set(usernames.map((name) => normalizeUsername(name))));
105
+ const results = [];
106
+
107
+ for (let index = 0; index < deduped.length; index += 1) {
108
+ const username = deduped[index];
109
+ if (signal?.aborted) {
110
+ throw new MinecraftToolkitError("Batch fetch aborted", { statusCode: 499 });
111
+ }
112
+
113
+ try {
114
+ const profile = await fetchPlayerProfile(username);
115
+ results.push({ username, profile });
116
+ } catch (error) {
117
+ results.push({ username, error });
118
+ }
119
+
120
+ if (index < deduped.length - 1 && delayMs > 0) {
121
+ await wait(delayMs, signal);
122
+ }
123
+ }
124
+
125
+ return results;
126
+ }
127
+
128
+ function wait(ms, signal) {
129
+ return new Promise((resolve, reject) => {
130
+ const timer = setTimeout(() => {
131
+ signal?.removeEventListener?.("abort", onAbort);
132
+ resolve();
133
+ }, ms);
134
+
135
+ function onAbort() {
136
+ clearTimeout(timer);
137
+ reject(new MinecraftToolkitError("Batch fetch aborted", { statusCode: 499 }));
138
+ }
139
+
140
+ if (signal) {
141
+ signal.addEventListener?.("abort", onAbort, { once: true });
142
+ }
143
+ });
144
+ }
@@ -0,0 +1,31 @@
1
+ import { isUUID, normalizeUUID, uuidWithDashes } from "./identity/index.js";
2
+ import { fetchPlayerProfile, fetchPlayerUUID, fetchUsernameByUUID } from "./profile/index.js";
3
+
4
+ export async function resolvePlayer(input) {
5
+ if (typeof input !== "string" || input.trim().length === 0) {
6
+ throw new TypeError("resolvePlayer input must be a non-empty string");
7
+ }
8
+
9
+ const raw = input.trim();
10
+
11
+ if (isUUID(raw)) {
12
+ const normalized = normalizeUUID(raw);
13
+ const identity = await fetchUsernameByUUID(normalized);
14
+ const profile = await fetchPlayerProfile(identity.name);
15
+ return {
16
+ id: uuidWithDashes(normalized),
17
+ name: identity.name,
18
+ skin: profile.skin ?? null,
19
+ cape: profile.cape ?? null,
20
+ };
21
+ }
22
+
23
+ const profile = await fetchPlayerProfile(raw);
24
+ const { id } = await fetchPlayerUUID(raw);
25
+ return {
26
+ id: uuidWithDashes(id),
27
+ name: profile.name,
28
+ skin: profile.skin ?? null,
29
+ cape: profile.cape ?? null,
30
+ };
31
+ }
@@ -0,0 +1,93 @@
1
+ import { PNG } from "pngjs";
2
+ import { MinecraftToolkitError } from "../errors.js";
3
+ import { fetchPlayerProfile } from "./profile/index.js";
4
+
5
+ const HEAD_REGION = { x: 8, y: 8, width: 8, height: 8 };
6
+
7
+ export async function fetchSkinMetadata(username, options = {}) {
8
+ const profile = await fetchPlayerProfile(username);
9
+ const skinUrl = profile.skin?.url ?? null;
10
+ let dominantColor = null;
11
+
12
+ if (skinUrl && options.dominantColor !== false) {
13
+ dominantColor = await computeSkinDominantColor(skinUrl, options.sampleRegion);
14
+ }
15
+
16
+ return {
17
+ id: profile.id,
18
+ name: profile.name,
19
+ skin: profile.skin,
20
+ cape: profile.cape,
21
+ hasCape: Boolean(profile.cape),
22
+ dominantColor,
23
+ };
24
+ }
25
+
26
+ export async function computeSkinDominantColor(url, region = HEAD_REGION) {
27
+ const png = await fetchPng(url);
28
+ const { width, height, data } = png;
29
+ const { x, y, width: regionWidth, height: regionHeight } = clampRegion(region, width, height);
30
+
31
+ let r = 0;
32
+ let g = 0;
33
+ let b = 0;
34
+ let samples = 0;
35
+
36
+ for (let row = y; row < y + regionHeight; row += 1) {
37
+ for (let col = x; col < x + regionWidth; col += 1) {
38
+ const idx = (row * width + col) * 4;
39
+ const alpha = data[idx + 3] / 255;
40
+ if (alpha === 0) {
41
+ continue;
42
+ }
43
+ r += data[idx] * alpha;
44
+ g += data[idx + 1] * alpha;
45
+ b += data[idx + 2] * alpha;
46
+ samples += 1;
47
+ }
48
+ }
49
+
50
+ if (samples === 0) {
51
+ return null;
52
+ }
53
+
54
+ const avgR = Math.round(r / samples);
55
+ const avgG = Math.round(g / samples);
56
+ const avgB = Math.round(b / samples);
57
+ return rgbToHex(avgR, avgG, avgB);
58
+ }
59
+
60
+ async function fetchPng(url) {
61
+ const response = await fetch(url);
62
+ if (!response.ok) {
63
+ throw new MinecraftToolkitError(`Unable to load skin texture: ${url}`, {
64
+ statusCode: response.status,
65
+ });
66
+ }
67
+
68
+ const buffer = Buffer.from(await response.arrayBuffer());
69
+ try {
70
+ return PNG.sync.read(buffer);
71
+ } catch (error) {
72
+ throw new MinecraftToolkitError("Unable to decode PNG skin texture", {
73
+ statusCode: 500,
74
+ cause: error,
75
+ });
76
+ }
77
+ }
78
+
79
+ function clampRegion(region, width, height) {
80
+ const x = Math.max(0, Math.min(width - 1, region.x ?? HEAD_REGION.x));
81
+ const y = Math.max(0, Math.min(height - 1, region.y ?? HEAD_REGION.y));
82
+ const regionWidth = Math.min(region.width ?? HEAD_REGION.width, width - x);
83
+ const regionHeight = Math.min(region.height ?? HEAD_REGION.height, height - y);
84
+ return { x, y, width: regionWidth, height: regionHeight };
85
+ }
86
+
87
+ function rgbToHex(r, g, b) {
88
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
89
+ }
90
+
91
+ function toHex(value) {
92
+ return value.toString(16).padStart(2, "0");
93
+ }
@@ -0,0 +1,39 @@
1
+ import { MinecraftToolkitError } from "../errors.js";
2
+
3
+ export function decodeTexturePayload(properties = []) {
4
+ const texturesProperty = properties.find((prop) => prop.name === "textures");
5
+ if (!texturesProperty?.value) {
6
+ return null;
7
+ }
8
+
9
+ try {
10
+ const decoded = Buffer.from(texturesProperty.value, "base64").toString("utf8");
11
+ return JSON.parse(decoded);
12
+ } catch (error) {
13
+ throw new MinecraftToolkitError("Unable to decode Mojang texture payload", {
14
+ statusCode: 500,
15
+ cause: error,
16
+ });
17
+ }
18
+ }
19
+
20
+ export function getSkinURL(profile) {
21
+ return profile?.skin?.url ?? null;
22
+ }
23
+
24
+ export function getCapeURL(profile) {
25
+ return profile?.cape?.url ?? null;
26
+ }
27
+
28
+ export function getSkinModel(profile) {
29
+ const model = profile?.skin?.metadata?.model;
30
+ return model === "slim" ? "slim" : "default";
31
+ }
32
+
33
+ export function extractTextureHash(url) {
34
+ if (!url) {
35
+ return null;
36
+ }
37
+ const match = url.match(/\/texture\/([A-Za-z0-9]+)/);
38
+ return match ? match[1] : null;
39
+ }
@@ -0,0 +1,147 @@
1
+ import { createSocket } from "node:dgram";
2
+ import { randomBytes } from "node:crypto";
3
+ import { DEFAULT_BEDROCK_PORT, RAKNET_MAGIC } from "../../constants.js";
4
+ import { MinecraftToolkitError } from "../../errors.js";
5
+ import { normalizeAddress } from "../../utils/validation.js";
6
+ import { resolveAddress } from "../../utils/network.js";
7
+ import { resolveTimeout, makeError } from "../shared.js";
8
+
9
+ export async function fetchBedrockServerStatus(address, options = {}) {
10
+ const normalized = normalizeAddress(address);
11
+ const { host, port } = resolveAddress(normalized, options.port, DEFAULT_BEDROCK_PORT);
12
+ const timeoutMs = resolveTimeout(options.timeoutMs);
13
+
14
+ return new Promise((resolve, reject) => {
15
+ const socket = createSocket("udp4");
16
+ let settled = false;
17
+
18
+ const timestamp = BigInt(Date.now());
19
+ const clientGuid = randomBytes(8);
20
+ const pingPacket = buildBedrockPingPacket(timestamp, clientGuid);
21
+
22
+ const timeout = setTimeout(() => {
23
+ rejectOnce(
24
+ new MinecraftToolkitError(`Timed out while querying Bedrock server ${host}:${port}`, {
25
+ statusCode: 504,
26
+ }),
27
+ );
28
+ }, timeoutMs);
29
+
30
+ function cleanup() {
31
+ clearTimeout(timeout);
32
+ socket.removeAllListeners();
33
+ socket.close();
34
+ }
35
+
36
+ function resolveOnce(value) {
37
+ if (settled) {
38
+ return;
39
+ }
40
+ settled = true;
41
+ cleanup();
42
+ resolve(value);
43
+ }
44
+
45
+ function rejectOnce(error) {
46
+ if (settled) {
47
+ return;
48
+ }
49
+ settled = true;
50
+ cleanup();
51
+ reject(
52
+ error instanceof MinecraftToolkitError
53
+ ? error
54
+ : makeError(`Unable to query Bedrock server ${host}:${port}`, error),
55
+ );
56
+ }
57
+
58
+ socket.once("error", (error) => {
59
+ rejectOnce(makeError(`Unable to reach Bedrock server ${host}:${port}`, error));
60
+ });
61
+
62
+ socket.on("message", (message) => {
63
+ try {
64
+ const status = parseBedrockStatus(message, host, port);
65
+ resolveOnce(status);
66
+ } catch (error) {
67
+ rejectOnce(
68
+ error instanceof MinecraftToolkitError
69
+ ? error
70
+ : makeError("Invalid Bedrock response", error),
71
+ );
72
+ }
73
+ });
74
+
75
+ socket.send(pingPacket, port, host, (error) => {
76
+ if (error) {
77
+ rejectOnce(makeError(`Failed to send Bedrock ping to ${host}:${port}`, error));
78
+ }
79
+ });
80
+ });
81
+ }
82
+
83
+ function buildBedrockPingPacket(timestamp, clientGuid) {
84
+ const buffer = Buffer.alloc(1 + 8 + 16 + 8);
85
+ let offset = 0;
86
+ buffer.writeUInt8(0x01, offset);
87
+ offset += 1;
88
+ buffer.writeBigInt64BE(timestamp, offset);
89
+ offset += 8;
90
+ RAKNET_MAGIC.copy(buffer, offset);
91
+ offset += RAKNET_MAGIC.length;
92
+ clientGuid.copy(buffer, offset);
93
+ return buffer;
94
+ }
95
+
96
+ function parseBedrockStatus(message, host, port) {
97
+ if (message.length < 35) {
98
+ throw new MinecraftToolkitError("Bedrock response too short");
99
+ }
100
+ const packetId = message.readUInt8(0);
101
+ if (packetId !== 0x1c) {
102
+ throw new MinecraftToolkitError(`Unexpected Bedrock packet id ${packetId}`);
103
+ }
104
+ const magic = message.subarray(17, 33);
105
+ if (!magic.equals(RAKNET_MAGIC)) {
106
+ throw new MinecraftToolkitError("Invalid RakNet magic");
107
+ }
108
+ const stringLength = message.readUInt16BE(33);
109
+ const totalLength = 35 + stringLength;
110
+ if (message.length < totalLength) {
111
+ throw new MinecraftToolkitError("Bedrock payload truncated");
112
+ }
113
+ const data = message.subarray(35, totalLength).toString("utf8");
114
+ const parts = data.split(";");
115
+ if (parts[0] !== "MCPE") {
116
+ throw new MinecraftToolkitError("Unexpected Bedrock edition identifier");
117
+ }
118
+
119
+ const protocol = Number.parseInt(parts[2] ?? "0", 10) || 0;
120
+ const versionName = parts[3] ?? "";
121
+ const onlinePlayers = Number.parseInt(parts[4] ?? "0", 10) || 0;
122
+ const maxPlayers = Number.parseInt(parts[5] ?? "0", 10) || 0;
123
+ const ipv4Port = Number.parseInt(parts[9] ?? `${port}`, 10) || port;
124
+ const ipv6Port = parts[10] ? Number.parseInt(parts[10], 10) || null : null;
125
+
126
+ return {
127
+ edition: "bedrock",
128
+ online: true,
129
+ host,
130
+ port,
131
+ motd: parts[1] ?? "",
132
+ version: {
133
+ protocol,
134
+ name: versionName,
135
+ },
136
+ players: {
137
+ online: onlinePlayers,
138
+ max: maxPlayers,
139
+ },
140
+ serverId: parts[6] ?? "",
141
+ map: parts[7] ?? "",
142
+ gamemode: parts[8] ?? "",
143
+ ipv4Port,
144
+ ipv6Port,
145
+ raw: data,
146
+ };
147
+ }
@@ -0,0 +1,259 @@
1
+ import { Socket } from "node:net";
2
+ import { DEFAULT_JAVA_PORT, DEFAULT_PROTOCOL_VERSION } from "../../constants.js";
3
+ import { MinecraftToolkitError } from "../../errors.js";
4
+ import { normalizeAddress } from "../../utils/validation.js";
5
+ import { resolveAddress } from "../../utils/network.js";
6
+ import { resolveTimeout, makeError } from "../shared.js";
7
+
8
+ export async function fetchJavaServerStatus(address, options = {}) {
9
+ const normalized = normalizeAddress(address);
10
+ const { host, port } = resolveAddress(normalized, options.port, DEFAULT_JAVA_PORT);
11
+ const timeoutMs = resolveTimeout(options.timeoutMs);
12
+ const protocolVersion = Number.isInteger(options.protocolVersion)
13
+ ? options.protocolVersion
14
+ : DEFAULT_PROTOCOL_VERSION;
15
+
16
+ return new Promise((resolve, reject) => {
17
+ const socket = new Socket();
18
+ socket.setNoDelay(true);
19
+
20
+ let buffer = Buffer.alloc(0);
21
+ let statusPayload = null;
22
+ let settled = false;
23
+ let pingStartedAt = 0;
24
+ let pingFallbackTimer;
25
+
26
+ const handshakePacket = buildHandshakePacket(host, port, protocolVersion);
27
+ const statusRequestPacket = buildStatusRequestPacket();
28
+
29
+ function cleanup() {
30
+ clearTimeout(pingFallbackTimer);
31
+ socket.removeAllListeners();
32
+ socket.destroy();
33
+ }
34
+
35
+ function resolveOnce(value) {
36
+ if (settled) {
37
+ return;
38
+ }
39
+ settled = true;
40
+ cleanup();
41
+ resolve(value);
42
+ }
43
+
44
+ function rejectOnce(error) {
45
+ if (settled) {
46
+ return;
47
+ }
48
+ settled = true;
49
+ cleanup();
50
+ reject(
51
+ error instanceof MinecraftToolkitError
52
+ ? error
53
+ : makeError(`Unable to query ${host}:${port}`, error),
54
+ );
55
+ }
56
+
57
+ function finish(latencyMs = null) {
58
+ resolveOnce(buildJavaStatus(statusPayload, host, port, latencyMs));
59
+ }
60
+
61
+ socket.setTimeout(timeoutMs, () => {
62
+ rejectOnce(
63
+ new MinecraftToolkitError(`Timed out while querying ${host}:${port}`, { statusCode: 504 }),
64
+ );
65
+ });
66
+
67
+ socket.on("error", (error) => {
68
+ rejectOnce(makeError(`Unable to reach ${host}:${port}`, error));
69
+ });
70
+
71
+ socket.on("close", () => {
72
+ if (!settled) {
73
+ if (statusPayload) {
74
+ finish(null);
75
+ } else {
76
+ rejectOnce(
77
+ new MinecraftToolkitError(
78
+ `Connection closed before status was received for ${host}:${port}`,
79
+ ),
80
+ );
81
+ }
82
+ }
83
+ });
84
+
85
+ socket.on("data", (chunk) => {
86
+ buffer = Buffer.concat([buffer, chunk]);
87
+ processPackets();
88
+ });
89
+
90
+ socket.connect(port, host, () => {
91
+ try {
92
+ socket.write(handshakePacket);
93
+ socket.write(statusRequestPacket);
94
+ } catch (error) {
95
+ rejectOnce(makeError(`Failed to send status request to ${host}:${port}`, error));
96
+ }
97
+ });
98
+
99
+ function processPackets() {
100
+ while (true) {
101
+ const packet = extractPacket();
102
+ if (!packet) {
103
+ break;
104
+ }
105
+ const { id, payload } = packet;
106
+ if (id === 0 && !statusPayload) {
107
+ try {
108
+ statusPayload = parseStatusPayload(payload);
109
+ } catch (error) {
110
+ rejectOnce(makeError("Invalid status payload", error));
111
+ return;
112
+ }
113
+
114
+ try {
115
+ pingStartedAt = Date.now();
116
+ socket.write(buildPingPacket(pingStartedAt));
117
+ pingFallbackTimer = setTimeout(() => {
118
+ if (!settled) {
119
+ finish(null);
120
+ }
121
+ }, 200);
122
+ } catch {
123
+ finish(null);
124
+ }
125
+ } else if (id === 1 && statusPayload) {
126
+ const latency = Math.max(0, Date.now() - pingStartedAt);
127
+ finish(latency);
128
+ }
129
+ }
130
+ }
131
+
132
+ function extractPacket() {
133
+ try {
134
+ const { value: length, size: lengthSize } = decodeVarInt(buffer, 0);
135
+ if (buffer.length < lengthSize + length) {
136
+ return null;
137
+ }
138
+ const packetData = buffer.subarray(lengthSize, lengthSize + length);
139
+ buffer = buffer.subarray(lengthSize + length);
140
+ const { value: packetId, size: idSize } = decodeVarInt(packetData, 0);
141
+ const payload = packetData.subarray(idSize);
142
+ return { id: packetId, payload };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+ });
148
+ }
149
+
150
+ function buildHandshakePacket(host, port, protocolVersion) {
151
+ const hostBytes = Buffer.from(host, "utf8");
152
+ const payload = Buffer.concat([
153
+ encodeVarInt(protocolVersion),
154
+ encodeVarInt(hostBytes.length),
155
+ hostBytes,
156
+ writePort(port),
157
+ encodeVarInt(1),
158
+ ]);
159
+ return createPacket(0, payload);
160
+ }
161
+
162
+ function buildStatusRequestPacket() {
163
+ return createPacket(0, Buffer.alloc(0));
164
+ }
165
+
166
+ function buildPingPacket(timestampMs) {
167
+ const payload = Buffer.alloc(8);
168
+ payload.writeBigInt64BE(BigInt(timestampMs));
169
+ return createPacket(1, payload);
170
+ }
171
+
172
+ function createPacket(packetId, payload) {
173
+ const payloadBuffer = Buffer.concat([encodeVarInt(packetId), payload]);
174
+ const lengthBuffer = encodeVarInt(payloadBuffer.length);
175
+ return Buffer.concat([lengthBuffer, payloadBuffer]);
176
+ }
177
+
178
+ function encodeVarInt(value) {
179
+ const bytes = [];
180
+ let current = value >>> 0;
181
+ do {
182
+ let byte = current & 0x7f;
183
+ current >>>= 7;
184
+ if (current !== 0) {
185
+ byte |= 0x80;
186
+ }
187
+ bytes.push(byte);
188
+ } while (current !== 0);
189
+ return Buffer.from(bytes);
190
+ }
191
+
192
+ function decodeVarInt(buffer, offset = 0) {
193
+ let numRead = 0;
194
+ let result = 0;
195
+ let byte = 0;
196
+ do {
197
+ if (offset + numRead >= buffer.length) {
198
+ throw new RangeError("VarInt extends beyond buffer");
199
+ }
200
+ byte = buffer[offset + numRead];
201
+ result |= (byte & 0x7f) << (7 * numRead);
202
+ numRead += 1;
203
+ if (numRead > 5) {
204
+ throw new RangeError("VarInt is too big");
205
+ }
206
+ } while ((byte & 0x80) === 0x80);
207
+ return { value: result, size: numRead };
208
+ }
209
+
210
+ function parseStatusPayload(payload) {
211
+ const { value: stringLength, size } = decodeVarInt(payload, 0);
212
+ const end = size + stringLength;
213
+ if (payload.length < end) {
214
+ throw new RangeError("Status string exceeds payload length");
215
+ }
216
+ const json = payload.subarray(size, end).toString("utf8");
217
+ return JSON.parse(json);
218
+ }
219
+
220
+ function buildJavaStatus(payload, host, port, latencyMs) {
221
+ return {
222
+ edition: "java",
223
+ online: true,
224
+ host,
225
+ port,
226
+ version: payload.version ?? null,
227
+ players: payload.players ?? null,
228
+ motd: stringifyDescription(payload.description) ?? null,
229
+ favicon: payload.favicon ?? null,
230
+ latencyMs,
231
+ raw: payload,
232
+ };
233
+ }
234
+
235
+ function stringifyDescription(description) {
236
+ if (!description) {
237
+ return null;
238
+ }
239
+ if (typeof description === "string") {
240
+ return description;
241
+ }
242
+ if (Array.isArray(description)) {
243
+ return description.map((entry) => stringifyDescription(entry) ?? "").join("");
244
+ }
245
+ if (typeof description === "object") {
246
+ const text = description.text ?? "";
247
+ const extra = Array.isArray(description.extra)
248
+ ? description.extra.map((entry) => stringifyDescription(entry) ?? "").join("")
249
+ : "";
250
+ return `${text}${extra}` || null;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ function writePort(port) {
256
+ const buffer = Buffer.alloc(2);
257
+ buffer.writeUInt16BE(port, 0);
258
+ return buffer;
259
+ }