minecraft-toolkit 0.1.0 → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 26bz https://26bz.com/
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # minecraft-toolkit
2
+
3
+ <!-- automd:badges name="minecraft-toolkit" github="26bz/minecraft-toolkit" license -->
4
+
5
+ [![npm version](https://img.shields.io/npm/v/minecraft-toolkit)](https://npmjs.com/package/minecraft-toolkit)
6
+ [![npm downloads](https://img.shields.io/npm/dm/minecraft-toolkit)](https://npm.chart.dev/minecraft-toolkit)
7
+ [![license](https://img.shields.io/github/license/26bz/minecraft-toolkit)](https://github.com/26bz/minecraft-toolkit/blob/main/LICENSE)
8
+
9
+ <!-- /automd -->
10
+
11
+ Lightweight Mojang player utilities (profile, skin, UUID) for Node, Vite, and edge projects.
12
+
13
+ > This toolkit wraps Mojang APIs. Rate limits and availability still apply. Write endpoints (name change, skin upload) are not yet included.
14
+
15
+ ## Installation
16
+
17
+ <!-- automd:pm-install name="minecraft-toolkit" -->
18
+
19
+ ```sh
20
+ # ✨ Auto-detect
21
+ npx nypm install minecraft-toolkit
22
+
23
+ # npm
24
+ npm install minecraft-toolkit
25
+
26
+ # yarn
27
+ yarn add minecraft-toolkit
28
+
29
+ # pnpm
30
+ pnpm install minecraft-toolkit
31
+
32
+ # bun
33
+ bun install minecraft-toolkit
34
+
35
+ # deno
36
+ deno install minecraft-toolkit
37
+ ```
38
+
39
+ <!-- /automd -->
40
+
41
+ ## Core Helpers
42
+
43
+ ```ts
44
+ import {
45
+ fetchPlayerProfile,
46
+ fetchPlayerSkin,
47
+ fetchPlayerUUID,
48
+ fetchPlayerSummary,
49
+ fetchNameHistory,
50
+ fetchPlayers,
51
+ resolvePlayer,
52
+ fetchSkinMetadata,
53
+ } from "minecraft-toolkit";
54
+
55
+ const profile = await fetchPlayerProfile("26bz");
56
+ const summary = await fetchPlayerSummary("26bz");
57
+ const skin = await fetchPlayerSkin("26bz");
58
+ const uuid = await fetchPlayerUUID("26bz");
59
+ const history = await fetchNameHistory("069a79f444e94726a5befca90e38aaf5");
60
+ const batch = await fetchPlayers(["Notch", "26bz"], { delayMs: 50 });
61
+ const resolved = await resolvePlayer("069a79f444e94726a5befca90e38aaf5");
62
+ const skinMeta = await fetchSkinMetadata("26bz");
63
+ ```
64
+
65
+ Helpers are HTTP-agnostic and run anywhere `fetch` exists (Node 18+, Bun, Workers). All errors surface as `MinecraftToolkitError`.
66
+
67
+ ## Texture & Identity Utilities
68
+
69
+ ```ts
70
+ import {
71
+ isValidUsername,
72
+ isUUID,
73
+ normalizeUUID,
74
+ uuidWithDashes,
75
+ uuidWithoutDashes,
76
+ getSkinURL,
77
+ getCapeURL,
78
+ getSkinModel,
79
+ extractTextureHash,
80
+ } from "minecraft-toolkit";
81
+
82
+ isValidUsername("26bz"); // true
83
+ uuidWithDashes("069a79f444e94726a5befca90e38aaf5");
84
+ const skinUrl = getSkinURL(await fetchPlayerProfile("26bz"));
85
+ const hash = extractTextureHash(skinUrl);
86
+ const model = getSkinModel(skinUrl); // "slim" | "classic"
87
+ ```
88
+
89
+ ## Skin Metadata & Color Sampling
90
+
91
+ ```ts
92
+ import { fetchSkinMetadata, computeSkinDominantColor } from "minecraft-toolkit";
93
+
94
+ const meta = await fetchSkinMetadata("26bz", {
95
+ dominantColor: true,
96
+ sampleRegion: { x: 8, y: 8, width: 8, height: 8 },
97
+ });
98
+
99
+ console.log(meta.dominantColor); // e.g. "#f2d2a9"
100
+
101
+ const accent = await computeSkinDominantColor(meta.skin.url, {
102
+ x: 40,
103
+ y: 8,
104
+ width: 8,
105
+ height: 8,
106
+ });
107
+ ```
108
+
109
+ ## Account Helpers
110
+
111
+ A valid Microsoft/Xbox Live access token is required for `minecraftservices.com` endpoints. Missing or expired tokens throw `MinecraftToolkitError` with `statusCode: 401`.
112
+
113
+ ```ts
114
+ import {
115
+ fetchNameChangeInfo,
116
+ checkNameAvailability,
117
+ validateGiftCode,
118
+ fetchBlockedServers,
119
+ } from "minecraft-toolkit";
120
+
121
+ const accessToken = process.env.MC_ACCESS_TOKEN;
122
+
123
+ const windowInfo = await fetchNameChangeInfo(accessToken);
124
+ const availability = await checkNameAvailability("fresh_name", accessToken);
125
+ const isGiftValid = await validateGiftCode("ABCD-1234", accessToken);
126
+ const blockedServer = await fetchBlockedServers(); // no token required
127
+ ```
128
+
129
+ `validateGiftCode` returns `true`/`false` for 200/404 responses without throwing.
130
+
131
+ ## License
132
+
133
+ Published under the [MIT](https://github.com/26bz/minecraft-toolkit/blob/main/LICENSE) license.
134
+ Made by [26bz](https://github.com/26bz)
135
+ <br><br>
136
+ <a href="https://github.com/26bz/minecraft-toolkit/graphs/contributors">
137
+ <img src="https://contrib.rocks/image?repo=26bz/minecraft-toolkit" />
138
+ </a>
package/index.js ADDED
@@ -0,0 +1,22 @@
1
+ export {
2
+ fetchPlayerProfile,
3
+ fetchPlayerSkin,
4
+ fetchPlayerUUID,
5
+ fetchUsernameByUUID,
6
+ fetchNameHistory,
7
+ fetchPlayers,
8
+ fetchPlayerSummary,
9
+ playerExists,
10
+ hasSkinChanged,
11
+ } from "./src/player/profile/index.js";
12
+ export { fetchSkinMetadata, computeSkinDominantColor } from "./src/player/skin.js";
13
+ export { isValidUsername } from "./src/player/identity/index.js";
14
+ export { getSkinURL, getCapeURL, getSkinModel, extractTextureHash } from "./src/player/textures.js";
15
+ export { resolvePlayer } from "./src/player/resolve.js";
16
+ export {
17
+ fetchNameChangeInfo,
18
+ checkNameAvailability,
19
+ validateGiftCode,
20
+ fetchBlockedServers,
21
+ } from "./src/player/account/index.js";
22
+ export { createPlayerApp, createPlayerHandlers } from "./src/h3/routes.js";
package/package.json CHANGED
@@ -1,12 +1,74 @@
1
1
  {
2
2
  "name": "minecraft-toolkit",
3
- "version": "0.1.0",
4
- "description": "",
3
+ "version": "0.1.1",
4
+ "description": "Developer toolkit for working with Mojang Minecraft player data, skins, and utilities.",
5
+ "keywords": [
6
+ "minecraft",
7
+ "minecraft-api",
8
+ "minecraft-player",
9
+ "minecraft-skins",
10
+ "minecraft-tools",
11
+ "minecraft-utils",
12
+ "minecraft-uuid",
13
+ "mojang",
14
+ "mojang-api"
15
+ ],
16
+ "homepage": "https://github.com/26bz/minecraft-toolkit",
17
+ "bugs": {
18
+ "url": "https://github.com/26bz/minecraft-toolkit/issues"
19
+ },
5
20
  "license": "MIT",
6
- "author": "",
7
- "type": "commonjs",
8
- "main": "index.js",
21
+ "author": {
22
+ "name": "26bz",
23
+ "email": "26bz@proton.me",
24
+ "url": "https://26bz.com"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/26bz/minecraft-toolkit"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "index.js",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "type": "module",
37
+ "sideEffects": false,
38
+ "types": "./index.d.ts",
39
+ "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"
45
+ },
46
+ "dependencies": {
47
+ "h3": "2.0.1-rc.14",
48
+ "pngjs": "^7.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "automd": "^0.3.2",
52
+ "husky": "^9.1.7",
53
+ "lint-staged": "^16.3.2",
54
+ "oxfmt": "^0.36.0",
55
+ "oxlint": "^1.51.0",
56
+ "vitest": "^4.0.18"
57
+ },
58
+ "lint-staged": {
59
+ "*.{js,jsx,ts,tsx,mjs,cjs}": "pnpm run lint"
60
+ },
61
+ "engines": {
62
+ "node": ">=18"
63
+ },
9
64
  "scripts": {
10
- "test": "echo \"Error: no test specified\" && exit 1"
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"
11
73
  }
12
- }
74
+ }
@@ -0,0 +1,30 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ const require = createRequire(import.meta.url);
4
+ const pkg = require("../package.json");
5
+
6
+ export const PACKAGE_METADATA = {
7
+ name: pkg.name,
8
+ version: pkg.version,
9
+ description: pkg.description,
10
+ };
11
+
12
+ export const DEFAULT_JAVA_PORT = 25565;
13
+ export const DEFAULT_BEDROCK_PORT = 19132;
14
+ export const DEFAULT_CACHE_TTL_SECONDS = 30;
15
+ export const DEFAULT_TIMEOUT_MS = 5000;
16
+ export const DEFAULT_PROTOCOL_VERSION = 760; // Minecraft 1.20.4
17
+
18
+ export const MOJANG_PROFILE_BASE = "https://api.mojang.com/users/profiles/minecraft";
19
+ export const SESSION_PROFILE_BASE = "https://sessionserver.mojang.com/session/minecraft/profile";
20
+ export const NAME_HISTORY_BASE = "https://api.mojang.com/user/profiles";
21
+
22
+ export const USER_AGENT = `${pkg.name}/${pkg.version}`;
23
+ export const DEFAULT_HEADERS = {
24
+ accept: "application/json",
25
+ "user-agent": USER_AGENT,
26
+ };
27
+
28
+ export const RAKNET_MAGIC = Buffer.from([
29
+ 0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, 0x56, 0x78,
30
+ ]);
package/src/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ export class MinecraftToolkitError extends Error {
2
+ constructor(message, { statusCode = 500, cause } = {}) {
3
+ super(message);
4
+ this.name = "MinecraftToolkitError";
5
+ this.statusCode = statusCode;
6
+ this.cause = cause;
7
+ }
8
+ }
@@ -0,0 +1,84 @@
1
+ import { H3, defineHandler, definePlugin } from "h3";
2
+ import { MinecraftToolkitError } from "../errors.js";
3
+ import {
4
+ fetchPlayerProfile,
5
+ fetchPlayerSkin,
6
+ fetchPlayerSummary,
7
+ fetchPlayerUUID,
8
+ } from "../player/profile/index.js";
9
+ import { resolvePlayer } from "../player/resolve.js";
10
+
11
+ function requireParam(event, key, message) {
12
+ const value = event.context.params?.[key];
13
+ if (!value) {
14
+ throw new MinecraftToolkitError(message, { statusCode: 400 });
15
+ }
16
+ return value;
17
+ }
18
+
19
+ export function createPlayerHandlers() {
20
+ const profileHandler = defineHandler(async (event) => {
21
+ const username = requireParam(event, "username", "Username parameter is required");
22
+ return fetchPlayerProfile(username);
23
+ });
24
+
25
+ const skinHandler = defineHandler(async (event) => {
26
+ const username = requireParam(event, "username", "Username parameter is required");
27
+ return fetchPlayerSkin(username);
28
+ });
29
+
30
+ const summaryHandler = defineHandler(async (event) => {
31
+ const username = requireParam(event, "username", "Username parameter is required");
32
+ return fetchPlayerSummary(username);
33
+ });
34
+
35
+ const uuidHandler = defineHandler(async (event) => {
36
+ const username = requireParam(event, "username", "Username parameter is required");
37
+ return fetchPlayerUUID(username);
38
+ });
39
+
40
+ const resolverHandler = defineHandler(async (event) => {
41
+ const input = requireParam(event, "input", "Username or UUID parameter is required");
42
+ return resolvePlayer(input);
43
+ });
44
+
45
+ return {
46
+ profileHandler,
47
+ skinHandler,
48
+ summaryHandler,
49
+ uuidHandler,
50
+ resolverHandler,
51
+ };
52
+ }
53
+
54
+ export function createPlayerApp(options = {}) {
55
+ const app = new H3(options?.app);
56
+ const handlers = createPlayerHandlers();
57
+
58
+ app.get("/player/:username", handlers.profileHandler, {
59
+ meta: { category: "player", resource: "profile" },
60
+ });
61
+
62
+ app.get("/player/:username/skin", handlers.skinHandler, {
63
+ meta: { category: "player", resource: "skin" },
64
+ });
65
+
66
+ app.get("/player/:username/summary", handlers.summaryHandler, {
67
+ meta: { category: "player", resource: "summary" },
68
+ });
69
+
70
+ app.get("/player/:username/uuid", handlers.uuidHandler, {
71
+ meta: { category: "player", resource: "uuid" },
72
+ });
73
+
74
+ app.get("/player/:input/resolve", handlers.resolverHandler, {
75
+ meta: { category: "player", resource: "resolve" },
76
+ });
77
+
78
+ return { app, handlers };
79
+ }
80
+
81
+ export const playerPlugin = definePlugin((app) => {
82
+ const { handlers } = createPlayerApp({ app });
83
+ return handlers;
84
+ });
@@ -0,0 +1,76 @@
1
+ import { DEFAULT_HEADERS } from "../../constants.js";
2
+ import { MinecraftToolkitError } from "../../errors.js";
3
+ import { fetchJson } from "../../utils/http/index.js";
4
+
5
+ const API_BASE = "https://api.minecraftservices.com";
6
+
7
+ function assertAccessToken(accessToken) {
8
+ if (!accessToken || typeof accessToken !== "string") {
9
+ throw new MinecraftToolkitError("A valid access token is required", { statusCode: 401 });
10
+ }
11
+ return accessToken;
12
+ }
13
+
14
+ function authHeaders(accessToken) {
15
+ return {
16
+ Authorization: `Bearer ${assertAccessToken(accessToken)}`,
17
+ };
18
+ }
19
+
20
+ export async function fetchNameChangeInfo(accessToken) {
21
+ return fetchJson(`${API_BASE}/minecraft/profile/namechange`, {
22
+ headers: authHeaders(accessToken),
23
+ });
24
+ }
25
+
26
+ export async function checkNameAvailability(name, accessToken) {
27
+ if (!name || typeof name !== "string") {
28
+ throw new MinecraftToolkitError("Name is required", { statusCode: 400 });
29
+ }
30
+ return fetchJson(`${API_BASE}/minecraft/profile/name/${encodeURIComponent(name)}/available`, {
31
+ headers: authHeaders(accessToken),
32
+ });
33
+ }
34
+
35
+ export async function validateGiftCode(code, accessToken) {
36
+ if (!code || typeof code !== "string") {
37
+ throw new MinecraftToolkitError("Gift code is required", { statusCode: 400 });
38
+ }
39
+
40
+ const response = await fetch(`${API_BASE}/productvoucher/giftcode/${encodeURIComponent(code)}`, {
41
+ headers: {
42
+ ...DEFAULT_HEADERS,
43
+ ...authHeaders(accessToken),
44
+ },
45
+ });
46
+
47
+ if (response.status === 200 || response.status === 204) {
48
+ return true;
49
+ }
50
+
51
+ if (response.status === 404) {
52
+ return false;
53
+ }
54
+
55
+ throw new MinecraftToolkitError(`Failed to validate gift code ${code}`, {
56
+ statusCode: response.status,
57
+ });
58
+ }
59
+
60
+ export async function fetchBlockedServers() {
61
+ const response = await fetch(`https://sessionserver.mojang.com/blockedservers`, {
62
+ headers: DEFAULT_HEADERS,
63
+ });
64
+
65
+ if (!response.ok) {
66
+ throw new MinecraftToolkitError("Unable to fetch blocked servers", {
67
+ statusCode: response.status,
68
+ });
69
+ }
70
+
71
+ const text = await response.text();
72
+ return text
73
+ .split("\n")
74
+ .map((entry) => entry.trim())
75
+ .filter(Boolean);
76
+ }
@@ -0,0 +1,35 @@
1
+ import { MinecraftToolkitError } from "../../errors.js";
2
+
3
+ const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,16}$/;
4
+ const UUID_HEX = /^[0-9a-fA-F]+$/;
5
+
6
+ export function isValidUsername(username) {
7
+ return typeof username === "string" && USERNAME_REGEX.test(username.trim());
8
+ }
9
+
10
+ export function isUUID(value) {
11
+ if (typeof value !== "string") {
12
+ return false;
13
+ }
14
+ const normalized = value.replace(/-/g, "");
15
+ return normalized.length === 32 && UUID_HEX.test(normalized);
16
+ }
17
+
18
+ export function normalizeUUID(uuid) {
19
+ if (!isUUID(uuid)) {
20
+ throw new MinecraftToolkitError("Invalid UUID", { statusCode: 400 });
21
+ }
22
+ return uuidWithoutDashes(uuid).toLowerCase();
23
+ }
24
+
25
+ export function uuidWithDashes(uuid) {
26
+ const compact = uuidWithoutDashes(uuid);
27
+ return compact.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5");
28
+ }
29
+
30
+ export function uuidWithoutDashes(uuid) {
31
+ if (typeof uuid !== "string") {
32
+ return uuid;
33
+ }
34
+ return uuid.replace(/-/g, "");
35
+ }
@@ -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,65 @@
1
+ const DEFAULT_CACHE_TTL_MS = 30 * 1000;
2
+
3
+ export class ResponseCache {
4
+ constructor(ttlMs = DEFAULT_CACHE_TTL_MS) {
5
+ this.ttlMs = ttlMs;
6
+ this.store = new Map();
7
+ }
8
+
9
+ get(key) {
10
+ const entry = this.store.get(key);
11
+ if (!entry) {
12
+ return undefined;
13
+ }
14
+
15
+ if (entry.expiresAt <= Date.now()) {
16
+ this.store.delete(key);
17
+ return undefined;
18
+ }
19
+
20
+ return entry.value;
21
+ }
22
+
23
+ set(key, value) {
24
+ this.store.set(key, {
25
+ value,
26
+ expiresAt: Date.now() + this.ttlMs,
27
+ });
28
+ }
29
+
30
+ delete(key) {
31
+ this.store.delete(key);
32
+ }
33
+
34
+ clear() {
35
+ this.store.clear();
36
+ }
37
+ }
38
+
39
+ export function createCache(options = {}) {
40
+ if (options.cache === false) {
41
+ return null;
42
+ }
43
+
44
+ const ttlSeconds = options.cache?.ttlSeconds ?? options.cacheTtl ?? options.ttlSeconds ?? null;
45
+ if (ttlSeconds === null || ttlSeconds === undefined) {
46
+ return new ResponseCache();
47
+ }
48
+
49
+ return new ResponseCache(Math.max(ttlSeconds, 0) * 1000);
50
+ }
51
+
52
+ export async function withCache(cache, key, resolver) {
53
+ if (!cache) {
54
+ return resolver();
55
+ }
56
+
57
+ const cached = cache.get(key);
58
+ if (cached) {
59
+ return cached;
60
+ }
61
+
62
+ const value = await resolver();
63
+ cache.set(key, value);
64
+ return value;
65
+ }
@@ -0,0 +1,23 @@
1
+ import { DEFAULT_HEADERS } from "../../constants.js";
2
+ import { MinecraftToolkitError } from "../../errors.js";
3
+
4
+ export async function fetchJson(url, { notFoundMessage, headers } = {}) {
5
+ const response = await fetch(url, {
6
+ headers: {
7
+ ...DEFAULT_HEADERS,
8
+ ...headers,
9
+ },
10
+ });
11
+
12
+ if (response.status === 404 && notFoundMessage) {
13
+ throw new MinecraftToolkitError(notFoundMessage, { statusCode: 404 });
14
+ }
15
+
16
+ if (!response.ok) {
17
+ throw new MinecraftToolkitError(`Failed to fetch ${url}`, {
18
+ statusCode: response.status,
19
+ });
20
+ }
21
+
22
+ return response.json();
23
+ }
@@ -0,0 +1,15 @@
1
+ import { validatePort } from "./validation.js";
2
+
3
+ export function resolveAddress(address, overridePort, fallbackPort) {
4
+ if (overridePort) {
5
+ return { host: address, port: validatePort(overridePort) };
6
+ }
7
+
8
+ const parts = address.split(":");
9
+ if (parts.length > 1 && parts[parts.length - 1] !== "") {
10
+ const extractedPort = parts.pop();
11
+ return { host: parts.join(":"), port: validatePort(extractedPort) };
12
+ }
13
+
14
+ return { host: address, port: fallbackPort };
15
+ }
@@ -0,0 +1,28 @@
1
+ import { MinecraftToolkitError } from "../errors.js";
2
+
3
+ export function normalizeAddress(address) {
4
+ if (!address || typeof address !== "string") {
5
+ throw new MinecraftToolkitError("Server address is required", { statusCode: 400 });
6
+ }
7
+
8
+ return address.trim();
9
+ }
10
+
11
+ export function normalizeUsername(username) {
12
+ if (!username || typeof username !== "string") {
13
+ throw new MinecraftToolkitError("Username is required", { statusCode: 400 });
14
+ }
15
+
16
+ return username.trim();
17
+ }
18
+
19
+ export function validatePort(port) {
20
+ const parsed = Number(port);
21
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
22
+ throw new MinecraftToolkitError("Port must be an integer between 1 and 65535", {
23
+ statusCode: 400,
24
+ });
25
+ }
26
+
27
+ return parsed;
28
+ }