minecraft-toolkit 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -128,6 +128,50 @@ const blockedServer = await fetchBlockedServers(); // no token required
128
128
 
129
129
  `validateGiftCode` returns `true`/`false` for 200/404 responses without throwing.
130
130
 
131
+ ## Server Status Helpers
132
+
133
+ Probe Java and Bedrock servers without bringing your own RakNet/TCP logic.
134
+
135
+ ```ts
136
+ import {
137
+ fetchServerStatus,
138
+ fetchJavaServerStatus,
139
+ fetchBedrockServerStatus,
140
+ } from "minecraft-toolkit";
141
+
142
+ const javaStatus = await fetchJavaServerStatus({ host: "mc.hypixel.net", port: 25565 });
143
+ const bedrockStatus = await fetchBedrockServerStatus({ host: "play.example.net", port: 19132 });
144
+
145
+ // fetchServerStatus picks the right probe based on the `edition` field
146
+ const autoStatus = await fetchServerStatus({ host: "my.realm.net", edition: "bedrock" });
147
+
148
+ console.log(javaStatus.players.online, bedrockStatus.motd.text);
149
+ ```
150
+
151
+ Both helpers normalize MOTD text, favicon/Base64 icons, latency, and version info. Errors surface as
152
+ `MinecraftToolkitError` with contextual status codes.
153
+
154
+ ## Minecraft Formatting Renderer
155
+
156
+ Convert legacy `§` or `&` codes into safe HTML fragments or CSS class spans.
157
+
158
+ ```ts
159
+ import { toHTML, generateCSS, stripCodes, hasCodes, convertPrefix } from "minecraft-toolkit";
160
+
161
+ const motd = "§aWelcome §lHeroes§r!";
162
+
163
+ const inline = toHTML(motd); // <span style="color: #55ff55">Welcome ...</span>
164
+
165
+ const classes = toHTML(motd, { mode: "class", classPrefix: "mc" });
166
+ const css = generateCSS(); // drop into a <style> tag
167
+
168
+ stripCodes(motd); // "Welcome Heroes!"
169
+ hasCodes(motd); // true
170
+ convertPrefix("&aHi", "toSection"); // "§aHi"
171
+ ```
172
+
173
+ `getMaps()` exposes the color and format metadata if you want to build custom renderers.
174
+
131
175
  ## License
132
176
 
133
177
  Published under the [MIT](https://github.com/26bz/minecraft-toolkit/blob/main/LICENSE) license.
package/index.d.ts ADDED
@@ -0,0 +1,213 @@
1
+ import type { H3 } from "h3";
2
+
3
+ export interface SkinTexture {
4
+ url: string;
5
+ metadata?: {
6
+ model?: "default" | "slim";
7
+ };
8
+ }
9
+
10
+ export interface CapeTexture {
11
+ url: string;
12
+ }
13
+
14
+ export interface PlayerProfile {
15
+ id: string;
16
+ name: string;
17
+ profile: Record<string, unknown>;
18
+ textures: Record<string, unknown>;
19
+ skin: SkinTexture | null;
20
+ cape: CapeTexture | null;
21
+ }
22
+
23
+ export interface PlayerSkin {
24
+ id: string;
25
+ name: string;
26
+ skin: SkinTexture | null;
27
+ cape: CapeTexture | null;
28
+ }
29
+
30
+ export interface PlayerSummary {
31
+ id: string;
32
+ name: string;
33
+ skinUrl: string | null;
34
+ capeUrl: string | null;
35
+ }
36
+
37
+ export interface BatchResult {
38
+ username: string;
39
+ profile?: PlayerProfile;
40
+ error?: unknown;
41
+ }
42
+
43
+ export interface BatchOptions {
44
+ delayMs?: number;
45
+ signal?: AbortSignal;
46
+ }
47
+
48
+ export interface SkinMetadataResult {
49
+ id: string;
50
+ name: string;
51
+ skin: SkinTexture | null;
52
+ cape: CapeTexture | null;
53
+ hasCape: boolean;
54
+ dominantColor: string | null;
55
+ }
56
+
57
+ export function fetchPlayerProfile(username: string): Promise<PlayerProfile>;
58
+ export function fetchPlayerSkin(username: string): Promise<PlayerSkin>;
59
+ export function fetchPlayerUUID(username: string): Promise<{ id: string; name: string }>;
60
+ export function fetchUsernameByUUID(uuid: string): Promise<{ id: string; name: string }>;
61
+ export function fetchNameHistory(uuid: string): Promise<{ name: string; changedAt: Date | null }[]>;
62
+ export function fetchPlayers(usernames: string[], options?: BatchOptions): Promise<BatchResult[]>;
63
+ export function fetchPlayerSummary(username: string): Promise<PlayerSummary>;
64
+ export function playerExists(username: string): Promise<boolean>;
65
+ export function hasSkinChanged(profileA: PlayerProfile, profileB: PlayerProfile): boolean;
66
+ export function fetchSkinMetadata(
67
+ username: string,
68
+ options?: {
69
+ dominantColor?: boolean;
70
+ sampleRegion?: { x?: number; y?: number; width?: number; height?: number };
71
+ },
72
+ ): Promise<SkinMetadataResult>;
73
+ export function fetchSkinDominantColor(
74
+ url: string,
75
+ region?: { x?: number; y?: number; width?: number; height?: number },
76
+ ): Promise<string | null>;
77
+ export function resolvePlayer(input: string): Promise<PlayerSkin>;
78
+
79
+ export function isValidUsername(username: string): boolean;
80
+ export function isUUID(value: string): boolean;
81
+ export function normalizeUUID(uuid: string): string;
82
+ export function uuidWithDashes(uuid: string): string;
83
+ export function uuidWithoutDashes(uuid: string): string;
84
+
85
+ export type FormattingMode = "inline" | "class";
86
+
87
+ export interface FormattingOptions {
88
+ mode?: FormattingMode;
89
+ classPrefix?: string;
90
+ animationName?: string;
91
+ obfuscatedSpeedMs?: number;
92
+ escapeHtml?: boolean;
93
+ }
94
+
95
+ export interface FormattingColorMeta {
96
+ name: string;
97
+ classSuffix: string;
98
+ hex: string;
99
+ }
100
+
101
+ export interface FormattingFormatMeta {
102
+ name: string;
103
+ classSuffix: string;
104
+ }
105
+
106
+ export interface FormattingMaps {
107
+ colors: Record<string, FormattingColorMeta>;
108
+ formats: Record<string, FormattingFormatMeta>;
109
+ }
110
+
111
+ export function toHTML(input: string, options?: FormattingOptions): string;
112
+ export function stripCodes(input: string): string;
113
+ export function generateCSS(options?: FormattingOptions): string;
114
+ export function hasCodes(input: string): boolean;
115
+ export function convertPrefix(input: string, direction?: "toSection" | "toAmpersand"): string;
116
+ export function getMaps(): FormattingMaps;
117
+
118
+ export function getSkinURL(profile: PlayerProfile | PlayerSkin): string | null;
119
+ export function getCapeURL(profile: PlayerProfile | PlayerSkin): string | null;
120
+ export function getSkinModel(profile: PlayerProfile | PlayerSkin): "default" | "slim";
121
+ export function extractTextureHash(url: string | null): string | null;
122
+
123
+ export type ServerEdition = "java" | "bedrock" | "auto";
124
+
125
+ export interface JavaServerStatusOptions {
126
+ port?: number;
127
+ timeoutMs?: number;
128
+ protocolVersion?: number;
129
+ }
130
+
131
+ export interface BedrockServerStatusOptions {
132
+ port?: number;
133
+ timeoutMs?: number;
134
+ }
135
+
136
+ export interface ServerStatusOptions extends JavaServerStatusOptions {
137
+ edition?: ServerEdition;
138
+ type?: ServerEdition;
139
+ }
140
+
141
+ export interface JavaServerStatus {
142
+ edition: "java";
143
+ online: boolean;
144
+ host: string;
145
+ port: number;
146
+ version: {
147
+ name?: string | null;
148
+ protocol?: number | null;
149
+ } | null;
150
+ players: {
151
+ max?: number | null;
152
+ online?: number | null;
153
+ sample?: Array<{ name: string; id: string }>;
154
+ } | null;
155
+ motd: string | null;
156
+ favicon: string | null;
157
+ latencyMs: number | null;
158
+ raw: Record<string, unknown>;
159
+ }
160
+
161
+ export interface BedrockServerStatus {
162
+ edition: "bedrock";
163
+ online: boolean;
164
+ host: string;
165
+ port: number;
166
+ motd: string;
167
+ version: {
168
+ protocol: number;
169
+ name: string;
170
+ };
171
+ players: {
172
+ online: number;
173
+ max: number;
174
+ };
175
+ serverId: string;
176
+ map: string;
177
+ gamemode: string;
178
+ ipv4Port: number;
179
+ ipv6Port: number | null;
180
+ raw: string;
181
+ }
182
+
183
+ export type ServerStatus = JavaServerStatus | BedrockServerStatus;
184
+
185
+ export function fetchServerStatus(address: string, options?: ServerStatusOptions): Promise<ServerStatus>;
186
+ export function fetchJavaServerStatus(
187
+ address: string,
188
+ options?: JavaServerStatusOptions,
189
+ ): Promise<JavaServerStatus>;
190
+ export function fetchBedrockServerStatus(
191
+ address: string,
192
+ options?: BedrockServerStatusOptions,
193
+ ): Promise<BedrockServerStatus>;
194
+
195
+ export interface PlayerHandlers {
196
+ profileHandler: any;
197
+ skinHandler: any;
198
+ summaryHandler: any;
199
+ uuidHandler: any;
200
+ resolverHandler: any;
201
+ }
202
+
203
+ export function createPlayerHandlers(): PlayerHandlers;
204
+ export function createPlayerApp(options?: { app?: ConstructorParameters<typeof H3>[0] }): {
205
+ app: H3;
206
+ handlers: PlayerHandlers;
207
+ };
208
+ export const playerPlugin: (app: H3) => PlayerHandlers;
209
+
210
+ export function fetchNameChangeInfo(accessToken: string): Promise<any>;
211
+ export function checkNameAvailability(name: string, accessToken: string): Promise<any>;
212
+ export function validateGiftCode(code: string, accessToken: string): Promise<boolean>;
213
+ export function fetchBlockedServers(): Promise<string[]>;
package/index.js CHANGED
@@ -20,3 +20,16 @@ export {
20
20
  fetchBlockedServers,
21
21
  } from "./src/player/account/index.js";
22
22
  export { createPlayerApp, createPlayerHandlers } from "./src/h3/routes.js";
23
+ export {
24
+ fetchServerStatus,
25
+ fetchJavaServerStatus,
26
+ fetchBedrockServerStatus,
27
+ } from "./src/server/status.js";
28
+ export {
29
+ toHTML,
30
+ stripCodes,
31
+ generateCSS,
32
+ hasCodes,
33
+ convertPrefix,
34
+ getMaps,
35
+ } from "./src/utils/formatting.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-toolkit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Developer toolkit for working with Mojang Minecraft player data, skins, and utilities.",
5
5
  "keywords": [
6
6
  "minecraft",
@@ -30,6 +30,7 @@
30
30
  "files": [
31
31
  "src",
32
32
  "index.js",
33
+ "index.d.ts",
33
34
  "README.md",
34
35
  "LICENSE"
35
36
  ],
@@ -37,11 +38,21 @@
37
38
  "sideEffects": false,
38
39
  "types": "./index.d.ts",
39
40
  "exports": {
40
- ".": "./index.js",
41
- "./player": "./src/player/profile/index.js",
42
- "./skin": "./src/player/skin.js",
43
- "./identity": "./src/player/identity/index.js",
44
- "./textures": "./src/player/textures.js"
41
+ ".": {
42
+ "types": "./index.d.ts",
43
+ "default": "./index.js"
44
+ }
45
+ },
46
+ "scripts": {
47
+ "test": "vitest",
48
+ "test:watch": "vitest watch",
49
+ "test:coverage": "vitest run --coverage",
50
+ "lint": "oxlint",
51
+ "lint:fix": "oxlint --fix",
52
+ "fmt": "oxfmt",
53
+ "fmt:check": "oxfmt --check",
54
+ "prepare": "husky install",
55
+ "docs:sync": "automd"
45
56
  },
46
57
  "dependencies": {
47
58
  "h3": "2.0.1-rc.14",
@@ -61,14 +72,5 @@
61
72
  "engines": {
62
73
  "node": ">=18"
63
74
  },
64
- "scripts": {
65
- "test": "vitest",
66
- "test:watch": "vitest watch",
67
- "test:coverage": "vitest run --coverage",
68
- "lint": "oxlint",
69
- "lint:fix": "oxlint --fix",
70
- "fmt": "oxfmt",
71
- "fmt:check": "oxfmt --check",
72
- "docs:sync": "automd"
73
- }
74
- }
75
+ "packageManager": "pnpm@10.30.2"
76
+ }
package/src/h3/routes.js CHANGED
@@ -1,12 +1,23 @@
1
- import { H3, defineHandler, definePlugin } from "h3";
1
+ import { H3, defineHandler, definePlugin, readBody, getQuery } from "h3";
2
2
  import { MinecraftToolkitError } from "../errors.js";
3
3
  import {
4
4
  fetchPlayerProfile,
5
5
  fetchPlayerSkin,
6
6
  fetchPlayerSummary,
7
7
  fetchPlayerUUID,
8
+ fetchPlayers,
9
+ fetchNameHistory,
10
+ playerExists,
8
11
  } from "../player/profile/index.js";
9
12
  import { resolvePlayer } from "../player/resolve.js";
13
+ import {
14
+ fetchNameChangeInfo,
15
+ checkNameAvailability,
16
+ validateGiftCode,
17
+ fetchBlockedServers,
18
+ } from "../player/account/index.js";
19
+ import { fetchServerStatus } from "../server/status.js";
20
+ import { normalizeAddress, validatePort } from "../utils/validation.js";
10
21
 
11
22
  function requireParam(event, key, message) {
12
23
  const value = event.context.params?.[key];
@@ -42,12 +53,89 @@ export function createPlayerHandlers() {
42
53
  return resolvePlayer(input);
43
54
  });
44
55
 
56
+ const nameHistoryHandler = defineHandler(async (event) => {
57
+ const uuid = requireParam(event, "uuid", "UUID parameter is required");
58
+ return fetchNameHistory(uuid);
59
+ });
60
+
61
+ const existsHandler = defineHandler(async (event) => {
62
+ const username = requireParam(event, "username", "Username parameter is required");
63
+ const exists = await playerExists(username);
64
+ return { username, exists };
65
+ });
66
+
67
+ const batchHandler = defineHandler(async (event) => {
68
+ const body = (await readBody(event)) ?? {};
69
+ const usernames = Array.isArray(body.usernames) ? body.usernames : null;
70
+ if (!usernames || usernames.length === 0) {
71
+ throw new MinecraftToolkitError("Body must include a non-empty usernames array", {
72
+ statusCode: 400,
73
+ });
74
+ }
75
+ const delayMs = typeof body.delayMs === "number" ? body.delayMs : undefined;
76
+ return fetchPlayers(usernames, { delayMs });
77
+ });
78
+
79
+ const nameChangeInfoHandler = defineHandler(async (event) => {
80
+ const token = requireAccessToken(event);
81
+ return fetchNameChangeInfo(token);
82
+ });
83
+
84
+ const nameAvailabilityHandler = defineHandler(async (event) => {
85
+ const name = requireParam(event, "name", "Name parameter is required");
86
+ const token = requireAccessToken(event);
87
+ return checkNameAvailability(name, token);
88
+ });
89
+
90
+ const giftCodeValidationHandler = defineHandler(async (event) => {
91
+ const token = requireAccessToken(event);
92
+ const body = (await readBody(event)) ?? {};
93
+ const code = typeof body.code === "string" ? body.code.trim() : "";
94
+ if (!code) {
95
+ throw new MinecraftToolkitError("Gift code is required", { statusCode: 400 });
96
+ }
97
+ const valid = await validateGiftCode(code, token);
98
+ return { code, valid };
99
+ });
100
+
101
+ const blockedServersHandler = defineHandler(async () => fetchBlockedServers());
102
+
103
+ const serverStatusHandler = defineHandler(async (event) => {
104
+ const address = normalizeAddress(
105
+ requireParam(event, "address", "Server address parameter is required"),
106
+ );
107
+ const query = getQuery(event);
108
+ const edition = typeof query.edition === "string" ? query.edition : undefined;
109
+ const port = typeof query.port === "string" ? validatePort(query.port) : undefined;
110
+ const timeoutMs =
111
+ typeof query.timeoutMs === "string" ? Number.parseInt(query.timeoutMs, 10) : undefined;
112
+ const protocolVersion =
113
+ typeof query.protocolVersion === "string"
114
+ ? Number.parseInt(query.protocolVersion, 10)
115
+ : undefined;
116
+
117
+ return fetchServerStatus(address, {
118
+ edition,
119
+ port,
120
+ timeoutMs,
121
+ protocolVersion,
122
+ });
123
+ });
124
+
45
125
  return {
46
126
  profileHandler,
47
127
  skinHandler,
48
128
  summaryHandler,
49
129
  uuidHandler,
50
130
  resolverHandler,
131
+ nameHistoryHandler,
132
+ existsHandler,
133
+ batchHandler,
134
+ nameChangeInfoHandler,
135
+ nameAvailabilityHandler,
136
+ giftCodeValidationHandler,
137
+ blockedServersHandler,
138
+ serverStatusHandler,
51
139
  };
52
140
  }
53
141
 
@@ -75,6 +163,38 @@ export function createPlayerApp(options = {}) {
75
163
  meta: { category: "player", resource: "resolve" },
76
164
  });
77
165
 
166
+ app.get("/player/:uuid/names", handlers.nameHistoryHandler, {
167
+ meta: { category: "player", resource: "name-history" },
168
+ });
169
+
170
+ app.get("/player/:username/exists", handlers.existsHandler, {
171
+ meta: { category: "player", resource: "exists" },
172
+ });
173
+
174
+ app.post("/players/batch", handlers.batchHandler, {
175
+ meta: { category: "player", resource: "batch" },
176
+ });
177
+
178
+ app.get("/account/namechange", handlers.nameChangeInfoHandler, {
179
+ meta: { category: "account", resource: "namechange" },
180
+ });
181
+
182
+ app.get("/account/name/:name/availability", handlers.nameAvailabilityHandler, {
183
+ meta: { category: "account", resource: "name-availability" },
184
+ });
185
+
186
+ app.post("/account/gift-code/validate", handlers.giftCodeValidationHandler, {
187
+ meta: { category: "account", resource: "gift-code" },
188
+ });
189
+
190
+ app.get("/account/blocked-servers", handlers.blockedServersHandler, {
191
+ meta: { category: "account", resource: "blocked-servers" },
192
+ });
193
+
194
+ app.get("/server/:address/status", handlers.serverStatusHandler, {
195
+ meta: { category: "server", resource: "status" },
196
+ });
197
+
78
198
  return { app, handlers };
79
199
  }
80
200
 
@@ -82,3 +202,17 @@ export const playerPlugin = definePlugin((app) => {
82
202
  const { handlers } = createPlayerApp({ app });
83
203
  return handlers;
84
204
  });
205
+
206
+ function requireAccessToken(event) {
207
+ const header = event.req?.headers?.get?.("authorization") ?? "";
208
+ if (!header.toLowerCase().startsWith("bearer ")) {
209
+ throw new MinecraftToolkitError("Authorization header with Bearer token is required", {
210
+ statusCode: 401,
211
+ });
212
+ }
213
+ const token = header.slice(7).trim();
214
+ if (!token) {
215
+ throw new MinecraftToolkitError("Access token is required", { statusCode: 401 });
216
+ }
217
+ return token;
218
+ }
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ import { DEFAULT_TIMEOUT_MS } from "../constants.js";
2
+ import { MinecraftToolkitError } from "../errors.js";
3
+
4
+ export function resolveTimeout(timeout) {
5
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
6
+ return timeout;
7
+ }
8
+ return DEFAULT_TIMEOUT_MS;
9
+ }
10
+
11
+ export function makeError(message, cause) {
12
+ return new MinecraftToolkitError(message, { cause });
13
+ }
@@ -0,0 +1,41 @@
1
+ import { fetchJavaServerStatus } from "./java/status.js";
2
+ import { fetchBedrockServerStatus } from "./bedrock/status.js";
3
+ import { MinecraftToolkitError } from "../errors.js";
4
+
5
+ export { fetchJavaServerStatus } from "./java/status.js";
6
+ export { fetchBedrockServerStatus } from "./bedrock/status.js";
7
+
8
+ export async function fetchServerStatus(address, options = {}) {
9
+ const { edition, type, ...rest } = options;
10
+ const target =
11
+ (typeof (edition ?? type) === "string" ? (edition ?? type).trim().toLowerCase() : null) ||
12
+ "java";
13
+
14
+ if (target === "java") {
15
+ return fetchJavaServerStatus(address, rest);
16
+ }
17
+
18
+ if (target === "bedrock") {
19
+ return fetchBedrockServerStatus(address, rest);
20
+ }
21
+
22
+ if (target === "auto") {
23
+ let javaError;
24
+ try {
25
+ return await fetchJavaServerStatus(address, rest);
26
+ } catch (error) {
27
+ javaError = error;
28
+ }
29
+
30
+ return fetchBedrockServerStatus(address, rest).catch((bedrockError) => {
31
+ throw new MinecraftToolkitError("Unable to query server status", {
32
+ statusCode: bedrockError.statusCode ?? javaError?.statusCode ?? 500,
33
+ cause: bedrockError,
34
+ });
35
+ });
36
+ }
37
+
38
+ throw new MinecraftToolkitError('Edition must be "java", "bedrock", or "auto"', {
39
+ statusCode: 400,
40
+ });
41
+ }
@@ -0,0 +1,281 @@
1
+ import { MinecraftToolkitError } from "../errors.js";
2
+
3
+ const DEFAULT_CLASS_PREFIX = "mc";
4
+ const DEFAULT_ANIMATION_NAME = "mc-obfuscated-flicker";
5
+ const DEFAULT_OBFUSCATED_SPEED_MS = 110;
6
+
7
+ const COLOR_CODES = freezeNested({
8
+ 0: { name: "black", classSuffix: "black", hex: "#000000" },
9
+ 1: { name: "dark_blue", classSuffix: "dark-blue", hex: "#0000aa" },
10
+ 2: { name: "dark_green", classSuffix: "dark-green", hex: "#00aa00" },
11
+ 3: { name: "dark_aqua", classSuffix: "dark-aqua", hex: "#00aaaa" },
12
+ 4: { name: "dark_red", classSuffix: "dark-red", hex: "#aa0000" },
13
+ 5: { name: "dark_purple", classSuffix: "dark-purple", hex: "#aa00aa" },
14
+ 6: { name: "gold", classSuffix: "gold", hex: "#ffaa00" },
15
+ 7: { name: "gray", classSuffix: "gray", hex: "#aaaaaa" },
16
+ 8: { name: "dark_gray", classSuffix: "dark-gray", hex: "#555555" },
17
+ 9: { name: "blue", classSuffix: "blue", hex: "#5555ff" },
18
+ a: { name: "green", classSuffix: "green", hex: "#55ff55" },
19
+ b: { name: "aqua", classSuffix: "aqua", hex: "#55ffff" },
20
+ c: { name: "red", classSuffix: "red", hex: "#ff5555" },
21
+ d: { name: "light_purple", classSuffix: "light-purple", hex: "#ff55ff" },
22
+ e: { name: "yellow", classSuffix: "yellow", hex: "#ffff55" },
23
+ f: { name: "white", classSuffix: "white", hex: "#ffffff" },
24
+ g: { name: "minecoin_gold", classSuffix: "minecoin-gold", hex: "#e1c158" },
25
+ h: { name: "material_quartz", classSuffix: "material-quartz", hex: "#ece6d8" },
26
+ i: { name: "material_iron", classSuffix: "material-iron", hex: "#cacaca" },
27
+ j: { name: "material_netherite", classSuffix: "material-netherite", hex: "#4b4946" },
28
+ n: { name: "material_redstone", classSuffix: "material-redstone", hex: "#b02e26" },
29
+ p: { name: "material_prismarine", classSuffix: "material-prismarine", hex: "#1ba19b" },
30
+ q: { name: "material_obsidian", classSuffix: "material-obsidian", hex: "#0b0b0b" },
31
+ s: { name: "material_crimson", classSuffix: "material-crimson", hex: "#a02c44" },
32
+ t: { name: "material_gold", classSuffix: "material-gold", hex: "#d8af48" },
33
+ u: { name: "material_emerald", classSuffix: "material-emerald", hex: "#30c67c" },
34
+ v: { name: "material_diamond", classSuffix: "material-diamond", hex: "#5be5e5" },
35
+ });
36
+
37
+ const FORMAT_CODES = freezeNested({
38
+ k: { name: "obfuscated", classSuffix: "obfuscated" },
39
+ l: { name: "bold", classSuffix: "bold" },
40
+ m: { name: "strikethrough", classSuffix: "strikethrough" },
41
+ n: { name: "underline", classSuffix: "underline" },
42
+ o: { name: "italic", classSuffix: "italic" },
43
+ });
44
+
45
+ const VALID_CODE_CHARS = new Set([...Object.keys(COLOR_CODES), ...Object.keys(FORMAT_CODES), "r"]);
46
+ const COLOR_KEYS = new Set(Object.keys(COLOR_CODES));
47
+
48
+ export function toHTML(input, options) {
49
+ const value = coerceInput(input);
50
+ if (!value) {
51
+ return "";
52
+ }
53
+
54
+ const resolved = resolveRenderOptions(options);
55
+ const segments = tokenize(value);
56
+
57
+ return segments.map((segment) => renderSegment(segment, resolved)).join("");
58
+ }
59
+
60
+ export function stripCodes(input) {
61
+ const value = coerceInput(input);
62
+ return value.replaceAll(/(?:§|&)[0-9a-fghijklmnpqrstuvr]/gi, "");
63
+ }
64
+
65
+ export function hasCodes(input) {
66
+ const value = coerceInput(input);
67
+ for (let i = 0; i < value.length - 1; i += 1) {
68
+ const candidate = value[i];
69
+ if (
70
+ (candidate === "§" || candidate === "&") &&
71
+ VALID_CODE_CHARS.has(value[i + 1]?.toLowerCase())
72
+ ) {
73
+ return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+
79
+ export function generateCSS(options) {
80
+ const resolved = resolveRenderOptions(options);
81
+ const lines = [];
82
+
83
+ lines.push(
84
+ `.${resolved.classPrefix}-segment { color: inherit; font-weight: inherit; font-style: inherit; }`,
85
+ );
86
+
87
+ Object.values(COLOR_CODES).forEach((entry) => {
88
+ lines.push(`.${resolved.classPrefix}-color-${entry.classSuffix} { color: ${entry.hex}; }`);
89
+ });
90
+
91
+ lines.push(`.${resolved.classPrefix}-format-bold { font-weight: 700; }`);
92
+ lines.push(`.${resolved.classPrefix}-format-italic { font-style: italic; }`);
93
+ lines.push(`.${resolved.classPrefix}-format-underline { text-decoration: underline; }`);
94
+ lines.push(`.${resolved.classPrefix}-format-strikethrough { text-decoration: line-through; }`);
95
+ lines.push(
96
+ `.${resolved.classPrefix}-format-underline.${resolved.classPrefix}-format-strikethrough { text-decoration: underline line-through; }`,
97
+ );
98
+ lines.push(
99
+ `.${resolved.classPrefix}-format-obfuscated { animation: ${resolved.animationName} ${resolved.obfuscatedSpeedMs}ms steps(10, end) infinite; display: inline-block; }`,
100
+ );
101
+
102
+ lines.push(
103
+ `@keyframes ${resolved.animationName} { 0%, 100% { opacity: 0.8; } 50% { opacity: 0.2; } }`,
104
+ );
105
+
106
+ return lines.join("\n");
107
+ }
108
+
109
+ export function convertPrefix(input, direction = "toSection") {
110
+ const value = coerceInput(input);
111
+
112
+ const normalized = direction?.toLowerCase();
113
+ if (normalized !== "tosection" && normalized !== "toampersand") {
114
+ throw new MinecraftToolkitError("direction must be either 'toSection' or 'toAmpersand'");
115
+ }
116
+
117
+ if (normalized === "toampersand") {
118
+ return value.replaceAll("§", "&");
119
+ }
120
+ return value.replaceAll("&", "§");
121
+ }
122
+
123
+ export function getMaps() {
124
+ return {
125
+ colors: COLOR_CODES,
126
+ formats: FORMAT_CODES,
127
+ };
128
+ }
129
+
130
+ function tokenize(input) {
131
+ const segments = [];
132
+ let color = null;
133
+ const formats = new Set();
134
+ let buffer = "";
135
+
136
+ for (let i = 0; i < input.length; i += 1) {
137
+ const char = input[i];
138
+ const next = input[i + 1]?.toLowerCase();
139
+
140
+ if ((char === "§" || char === "&") && next && VALID_CODE_CHARS.has(next)) {
141
+ if (buffer) {
142
+ segments.push({ text: buffer, color, formats: Array.from(formats) });
143
+ buffer = "";
144
+ }
145
+
146
+ if (COLOR_KEYS.has(next)) {
147
+ color = next;
148
+ formats.clear();
149
+ } else if (next === "r") {
150
+ color = null;
151
+ formats.clear();
152
+ } else {
153
+ formats.add(next);
154
+ }
155
+ i += 1;
156
+ continue;
157
+ }
158
+
159
+ buffer += char;
160
+ }
161
+
162
+ if (buffer) {
163
+ segments.push({ text: buffer, color, formats: Array.from(formats) });
164
+ }
165
+
166
+ return segments;
167
+ }
168
+
169
+ function renderSegment(segment, options) {
170
+ const safeText = options.escapeHtml ? escapeHtml(segment.text) : segment.text;
171
+ const needsStyling = segment.color || segment.formats.length;
172
+ if (!needsStyling) {
173
+ return safeText;
174
+ }
175
+
176
+ if (options.mode === "class") {
177
+ const classNames = buildClassNames(segment, options);
178
+ return classNames.length
179
+ ? `<span class="${classNames.join(" ")}">${safeText}</span>`
180
+ : safeText;
181
+ }
182
+
183
+ const inlineStyle = buildInlineStyle(segment, options);
184
+ return inlineStyle ? `<span style="${inlineStyle}">${safeText}</span>` : safeText;
185
+ }
186
+
187
+ function buildClassNames(segment, options) {
188
+ const classes = [`${options.classPrefix}-segment`];
189
+
190
+ if (segment.color) {
191
+ const colorMeta = COLOR_CODES[segment.color];
192
+ classes.push(`${options.classPrefix}-color-${colorMeta.classSuffix}`);
193
+ }
194
+
195
+ segment.formats.forEach((code) => {
196
+ const meta = FORMAT_CODES[code];
197
+ if (meta) {
198
+ classes.push(`${options.classPrefix}-format-${meta.classSuffix}`);
199
+ }
200
+ });
201
+
202
+ return classes;
203
+ }
204
+
205
+ function buildInlineStyle(segment, options) {
206
+ const declarations = [];
207
+ const textDecorations = new Set();
208
+
209
+ if (segment.color) {
210
+ declarations.push(`color: ${COLOR_CODES[segment.color].hex}`);
211
+ }
212
+
213
+ segment.formats.forEach((code) => {
214
+ switch (code) {
215
+ case "l":
216
+ declarations.push("font-weight: 700");
217
+ break;
218
+ case "o":
219
+ declarations.push("font-style: italic");
220
+ break;
221
+ case "m":
222
+ textDecorations.add("line-through");
223
+ break;
224
+ case "n":
225
+ textDecorations.add("underline");
226
+ break;
227
+ case "k":
228
+ declarations.push(
229
+ `animation: ${options.animationName} ${options.obfuscatedSpeedMs}ms steps(10, end) infinite`,
230
+ );
231
+ declarations.push("display: inline-block");
232
+ break;
233
+ default:
234
+ break;
235
+ }
236
+ });
237
+
238
+ if (textDecorations.size) {
239
+ declarations.push(`text-decoration: ${Array.from(textDecorations).join(" ")}`);
240
+ }
241
+
242
+ return declarations.join("; ");
243
+ }
244
+
245
+ function escapeHtml(value) {
246
+ return value
247
+ .replaceAll(/&/g, "&amp;")
248
+ .replaceAll(/</g, "&lt;")
249
+ .replaceAll(/>/g, "&gt;")
250
+ .replaceAll(/"/g, "&quot;")
251
+ .replaceAll(/'/g, "&#39;");
252
+ }
253
+
254
+ function coerceInput(input) {
255
+ if (input == null) {
256
+ return "";
257
+ }
258
+ return typeof input === "string" ? input : String(input);
259
+ }
260
+
261
+ function resolveRenderOptions(options = {}) {
262
+ const mode = options.mode === "class" ? "class" : "inline";
263
+ const classPrefix = options.classPrefix ?? DEFAULT_CLASS_PREFIX;
264
+ const animationName = options.animationName ?? DEFAULT_ANIMATION_NAME;
265
+ const obfuscatedSpeedMs = Number.isFinite(options.obfuscatedSpeedMs)
266
+ ? Number(options.obfuscatedSpeedMs)
267
+ : DEFAULT_OBFUSCATED_SPEED_MS;
268
+
269
+ return {
270
+ mode,
271
+ classPrefix,
272
+ animationName,
273
+ obfuscatedSpeedMs,
274
+ escapeHtml: options.escapeHtml !== false,
275
+ };
276
+ }
277
+
278
+ function freezeNested(map) {
279
+ Object.values(map).forEach((entry) => Object.freeze(entry));
280
+ return Object.freeze(map);
281
+ }