@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
582 lines (581 loc) • 25.4 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* ARCHITECTURE DOCUMENTATION: MinecraftDebugClient
* ================================================
*
* This class implements a client for the Minecraft Bedrock Edition debug protocol,
* allowing server-side code to connect to a running Minecraft instance's debug server.
*
* ## Overview
*
* When Minecraft Dedicated Server starts with script debugging enabled, it listens
* for debug connections (typically on port 19144). This client connects to that port
* and receives real-time events including:
*
* - **Statistics**: Performance metrics, entity counts, chunk loading, etc.
* - **Debug events**: Breakpoint hits, thread events, exceptions
* - **Print events**: Script console output
* - **Profiler captures**: CPU profiling data
*
* ## Connection Flow
*
* 1. Client connects to Minecraft's debug port
* 2. Minecraft sends ProtocolEvent with version and capabilities
* 3. Client responds with protocol handshake
* 4. Events flow continuously (StatEvent2 every tick, etc.)
*
* ## Integration Points
*
* - **DedicatedServer.ts**: Starts debug listener, creates this client
* - **HttpServer.ts**: Subscribes to events and broadcasts to web clients
* - **DebugPanel.tsx**: Web UI that displays the statistics
*
* ## Usage
*
* ```typescript
* const client = new MinecraftDebugClient();
* client.onStats.subscribe((_, stats) => console.log(stats));
* client.onConnected.subscribe(() => console.log("Connected!"));
* await client.connect("localhost", 19144);
* ```
*/
const net_1 = require("net");
const ste_events_1 = require("ste-events");
const Log_1 = __importDefault(require("../core/Log"));
const DebugMessageStreamParser_1 = __importDefault(require("./DebugMessageStreamParser"));
const IMinecraftDebugProtocol_1 = require("./IMinecraftDebugProtocol");
const CONNECTION_RETRY_ATTEMPTS = 5;
const CONNECTION_RETRY_WAIT_MS = 1000;
const CONNECTION_TIMEOUT_MS = 5000; // Timeout for each connection attempt
const PROTOCOL_HANDSHAKE_TIMEOUT_MS = 10000; // Timeout waiting for protocol event
class MinecraftDebugClient {
_socket;
_parser;
_state = IMinecraftDebugProtocol_1.DebugConnectionState.Disconnected;
_host = "localhost";
_port = 19144;
_protocolVersion = IMinecraftDebugProtocol_1.ProtocolVersion.Unknown;
_clientProtocolVersion = IMinecraftDebugProtocol_1.ProtocolVersion.SupportBreakpointsAsRequest;
_targetModuleUuid;
_plugins = [];
_capabilities = {
supportsCommands: false,
supportsProfiler: false,
supportsBreakpointsAsRequest: false,
};
_lastStatTick = 0;
_errorMessage;
_passcode;
// Diagnostic tracking
_lastDataReceivedTime = 0;
_messageCount = 0;
_statWarningLogged = false;
_statusCheckInterval;
_pendingRequests = new Map();
_requestSeq = 0;
// Events
_onConnected = new ste_events_1.EventDispatcher();
_onDisconnected = new ste_events_1.EventDispatcher();
_onStats = new ste_events_1.EventDispatcher();
_onStopped = new ste_events_1.EventDispatcher();
_onThread = new ste_events_1.EventDispatcher();
_onPrint = new ste_events_1.EventDispatcher();
_onError = new ste_events_1.EventDispatcher();
_onProtocol = new ste_events_1.EventDispatcher();
_onProfilerCapture = new ste_events_1.EventDispatcher();
get onConnected() {
return this._onConnected.asEvent();
}
get onDisconnected() {
return this._onDisconnected.asEvent();
}
get onStats() {
return this._onStats.asEvent();
}
get onStopped() {
return this._onStopped.asEvent();
}
get onThread() {
return this._onThread.asEvent();
}
get onPrint() {
return this._onPrint.asEvent();
}
get onError() {
return this._onError.asEvent();
}
get onProtocol() {
return this._onProtocol.asEvent();
}
get onProfilerCapture() {
return this._onProfilerCapture.asEvent();
}
get state() {
return this._state;
}
get isConnected() {
return this._state === IMinecraftDebugProtocol_1.DebugConnectionState.Connected;
}
get sessionInfo() {
return {
state: this._state,
host: this._host,
port: this._port,
protocolVersion: this._protocolVersion,
targetModuleUuid: this._targetModuleUuid,
plugins: this._plugins,
capabilities: this._capabilities,
lastStatTick: this._lastStatTick,
errorMessage: this._errorMessage,
};
}
constructor() {
this._parser = new DebugMessageStreamParser_1.default();
this._parser.onMessage.subscribe((_, message) => {
this._handleMessage(message);
});
this._parser.onError.subscribe((_, error) => {
Log_1.default.error(`Debug protocol parse error: ${error.message}`);
this._onError.dispatch(this, error);
});
}
/**
* Connect to the Minecraft debug server.
* This method includes connection timeouts and retries to handle slow server startup.
* The protocol handshake completes asynchronously after the socket connects.
*/
async connect(host = "localhost", port = 19144, passcode) {
if (this._state === IMinecraftDebugProtocol_1.DebugConnectionState.Connected || this._state === IMinecraftDebugProtocol_1.DebugConnectionState.Connecting) {
throw new Error("Already connected or connecting");
}
this._host = host;
this._port = port;
this._passcode = passcode;
this._state = IMinecraftDebugProtocol_1.DebugConnectionState.Connecting;
this._errorMessage = undefined;
let socket;
let lastError;
// Retry connection with exponential backoff
Log_1.default.debug(`[Debug] Starting connection attempts to ${host}:${port} (max ${CONNECTION_RETRY_ATTEMPTS} attempts)...`);
for (let attempt = 0; attempt < CONNECTION_RETRY_ATTEMPTS; attempt++) {
const waitMs = attempt > 0 ? CONNECTION_RETRY_WAIT_MS * Math.pow(2, attempt - 1) : 0;
if (waitMs > 0) {
Log_1.default.debug(`[Debug] Waiting ${waitMs}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
Log_1.default.debug(`[Debug] Connection attempt ${attempt + 1}/${CONNECTION_RETRY_ATTEMPTS} to ${host}:${port}...`);
try {
socket = await new Promise((resolve, reject) => {
const client = (0, net_1.createConnection)({ host, port });
// Set a connection timeout
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`Connection timeout after ${CONNECTION_TIMEOUT_MS}ms`));
}, CONNECTION_TIMEOUT_MS);
client.on("connect", () => {
clearTimeout(timeout);
client.removeAllListeners();
resolve(client);
});
client.on("close", () => {
clearTimeout(timeout);
reject(new Error("Connection closed"));
});
client.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
break;
}
catch (e) {
lastError = e;
Log_1.default.debug(`[Debug] Connection attempt ${attempt + 1} failed: ${e.message}`);
}
}
if (!socket) {
this._state = IMinecraftDebugProtocol_1.DebugConnectionState.Error;
this._errorMessage = `Failed to connect to ${host}:${port} after ${CONNECTION_RETRY_ATTEMPTS} attempts: ${lastError?.message || "unknown error"}`;
Log_1.default.message(`[Debug] Connection failed: ${lastError?.message || "unknown error"}`);
throw new Error(this._errorMessage);
}
Log_1.default.debug(`[Debug] Socket connection established to ${host}:${port}`);
this._socket = socket;
this._parser.reset();
this._lastDataReceivedTime = Date.now();
this._messageCount = 0;
// Set TCP keep-alive to detect dead connections
socket.setKeepAlive(true, 30000); // 30 second keep-alive
// Set up socket event handlers
socket.on("data", (data) => {
this._lastDataReceivedTime = Date.now();
this._messageCount++;
Log_1.default.verbose(`[DebugClient] Socket received ${data.length} bytes of raw data (msg #${this._messageCount})`);
this._parser.write(data);
});
socket.on("error", (e) => {
Log_1.default.message(`[DebugClient] Socket ERROR event: ${e.message}`);
this._handleDisconnect(`Socket error: ${e.message}`);
});
socket.on("close", () => {
Log_1.default.debug(`[DebugClient] Socket CLOSE event`);
this._handleDisconnect("Socket closed");
});
socket.on("end", () => {
Log_1.default.debug(`[DebugClient] Socket END event - remote side closed connection`);
});
socket.on("timeout", () => {
Log_1.default.debug(`[DebugClient] Socket TIMEOUT event`);
});
socket.on("drain", () => {
Log_1.default.verbose(`[DebugClient] Socket DRAIN event - write buffer emptied`);
});
// Periodic status check - log if we haven't received data in a while
this._statusCheckInterval = setInterval(() => {
if (this._state === IMinecraftDebugProtocol_1.DebugConnectionState.Connected) {
const silentMs = Date.now() - this._lastDataReceivedTime;
const socketState = this._socket
? `readable=${this._socket.readable}, writable=${this._socket.writable}, destroyed=${this._socket.destroyed}`
: "no socket";
Log_1.default.verbose(`[DebugClient] STATUS CHECK: Connected, silent for ${silentMs}ms, ${this._messageCount} msgs received, ${socketState}`);
// If we haven't received any data for 10 seconds after connecting, something is wrong.
// Only log this warning once to avoid spamming the console every 5 seconds.
if (silentMs > 10000 && this._messageCount <= 2 && !this._statWarningLogged) {
this._statWarningLogged = true;
Log_1.default.debug(`[DebugClient] WARNING: No stat events received after ${silentMs}ms - Minecraft may not be sending stats`);
}
}
}, 5000);
// Set a timeout for the protocol handshake
// If we don't receive a ProtocolEvent within the timeout, disconnect
const handshakeTimeout = setTimeout(() => {
if (this._state === IMinecraftDebugProtocol_1.DebugConnectionState.Connecting) {
Log_1.default.message(`[Debug] Protocol handshake TIMEOUT after ${PROTOCOL_HANDSHAKE_TIMEOUT_MS}ms - no ProtocolEvent received`);
this._handleDisconnect("Protocol handshake timeout - no ProtocolEvent received");
}
}, PROTOCOL_HANDSHAKE_TIMEOUT_MS);
// Clear the timeout when we receive the protocol event (handled in _handleProtocolEvent)
this._handshakeTimeoutId = handshakeTimeout;
// Now wait for the ProtocolEvent from Minecraft
// The _handleMessage method will complete the connection handshake
Log_1.default.debug(`[Debug] Socket connected, waiting for ProtocolEvent (timeout: ${PROTOCOL_HANDSHAKE_TIMEOUT_MS}ms)...`);
}
// Timeout ID for protocol handshake
_handshakeTimeoutId;
/**
* Disconnect from the debug server.
*/
disconnect() {
if (this._socket) {
this._socket.destroy();
this._socket = undefined;
}
this._handleDisconnect("Client requested disconnect");
}
/**
* Send a Minecraft command.
*/
sendCommand(command, dimensionType = "overworld") {
if (!this.isConnected) {
throw new Error("Not connected to debug server");
}
if (this._protocolVersion < IMinecraftDebugProtocol_1.ProtocolVersion.SupportProfilerCaptures) {
this._sendMessage({
type: "minecraftCommand",
command: command,
dimension_type: dimensionType,
});
}
else {
this._sendMessage({
type: "minecraftCommand",
command: {
command: command,
dimension_type: dimensionType,
},
});
}
}
/**
* Resume execution (continue from breakpoint).
*/
resume() {
this._sendMessage({ type: "resume" });
}
/**
* Pause execution.
*/
pause() {
this._sendMessage({ type: "pause" });
}
/**
* Start the profiler.
*/
startProfiler() {
if (!this._capabilities.supportsProfiler) {
throw new Error("Profiler not supported by this Minecraft version");
}
this._sendMessage({
type: "startProfiler",
profiler: {
target_module_uuid: this._targetModuleUuid,
},
});
}
/**
* Stop the profiler and capture data.
*/
stopProfiler(capturesPath) {
if (!this._capabilities.supportsProfiler) {
throw new Error("Profiler not supported by this Minecraft version");
}
this._sendMessage({
type: "stopProfiler",
profiler: {
captures_path: capturesPath,
target_module_uuid: this._targetModuleUuid,
},
});
}
/**
* Send a message to the debug server.
*/
_sendMessage(envelope) {
if (!this._socket) {
Log_1.default.message(`[DebugClient] SEND FAILED: No socket! Message: ${JSON.stringify(envelope).substring(0, 200)}`);
return;
}
const json = JSON.stringify(envelope);
Log_1.default.verbose(`[DebugClient] SENDING (${json.length} bytes): ${json.substring(0, 300)}`);
const jsonBuffer = Buffer.from(json);
// Length prefix: 8 hex digits + newline
const messageLength = jsonBuffer.byteLength + 1; // +1 for trailing newline
let lengthStr = "00000000" + messageLength.toString(16) + "\n";
lengthStr = lengthStr.substring(lengthStr.length - 9);
const lengthBuffer = Buffer.from(lengthStr);
const newline = Buffer.from("\n");
const buffer = Buffer.concat([lengthBuffer, jsonBuffer, newline]);
this._socket.write(buffer);
}
/**
* Handle incoming messages.
*/
_handleMessage(envelope) {
Log_1.default.verbose(`[DebugClient] Processing message type: ${envelope.type}`);
if (envelope.type === "event") {
const eventEnvelope = envelope;
const eventType = eventEnvelope.event?.type || "unknown";
Log_1.default.verbose(`[DebugClient] Event type: ${eventType}`);
this._handleEvent(eventEnvelope.event);
}
else if (envelope.type === "response") {
Log_1.default.verbose(`[DebugClient] Response for command: ${envelope.command}`);
this._handleResponse(envelope);
}
else if (envelope.type === "protocol") {
Log_1.default.verbose(`[DebugClient] Received protocol message (as envelope.type=protocol)`);
// Handle protocol messages that come as envelope.type="protocol" instead of event
this._handleProtocolEvent(envelope);
}
else {
Log_1.default.message(`[DebugClient] UNKNOWN message type: ${envelope.type} - full envelope: ${JSON.stringify(envelope).substring(0, 500)}`);
}
}
/**
* Handle event messages from Minecraft.
*/
_handleEvent(event) {
switch (event.type) {
case "ProtocolEvent":
Log_1.default.verbose(`[DebugClient] Received ProtocolEvent`);
this._handleProtocolEvent(event);
break;
case "StatEvent2":
this._handleStatEvent(event);
break;
case "StoppedEvent":
Log_1.default.verbose(`[DebugClient] Received StoppedEvent`);
this._onStopped.dispatch(this, event);
break;
case "ThreadEvent":
Log_1.default.verbose(`[DebugClient] Received ThreadEvent`);
this._onThread.dispatch(this, event);
break;
case "PrintEvent":
this._onPrint.dispatch(this, event);
break;
case "NotificationEvent":
Log_1.default.verbose(`Debug notification: ${event.message}`);
break;
case "ProfilerCapture":
Log_1.default.verbose("Received profiler capture");
this._onProfilerCapture.dispatch(this, event);
break;
default:
Log_1.default.verbose(`Unknown debug event type: ${event.type}`);
}
}
/**
* Handle protocol handshake event.
*/
_handleProtocolEvent(event) {
Log_1.default.debug(`[DebugClient] ProtocolEvent received: version=${event.version}, plugins=${event.plugins?.length || 0}`);
Log_1.default.verbose(`[DebugClient] Server version: ${event.version}, Our version: ${this._clientProtocolVersion}`);
Log_1.default.verbose(`[DebugClient] Plugins: ${JSON.stringify(event.plugins)}`);
Log_1.default.verbose(`[DebugClient] Requires passcode: ${event.require_passcode}`);
this._protocolVersion = Math.min(event.version, this._clientProtocolVersion);
this._plugins = event.plugins || [];
// Determine capabilities based on protocol version
this._capabilities = {
supportsCommands: this._protocolVersion >= IMinecraftDebugProtocol_1.ProtocolVersion.SupportProfilerCaptures,
supportsProfiler: this._protocolVersion >= IMinecraftDebugProtocol_1.ProtocolVersion.SupportProfilerCaptures,
supportsBreakpointsAsRequest: this._protocolVersion >= IMinecraftDebugProtocol_1.ProtocolVersion.SupportBreakpointsAsRequest,
};
// Auto-select the first plugin if no target module specified
// This is required to receive stats events for that module
if (!this._targetModuleUuid && this._plugins.length > 0) {
this._targetModuleUuid = this._plugins[0].module_uuid;
Log_1.default.debug(`[DebugClient] Auto-selected plugin: ${this._plugins[0].name} (${this._targetModuleUuid})`);
}
// Log available plugins
if (this._plugins.length > 0) {
Log_1.default.verbose(`[DebugClient] Available plugins: ${this._plugins.map((p) => `${p.name} (${p.module_uuid})`).join(", ")}`);
}
else {
Log_1.default.verbose(`[DebugClient] No plugins available - stats may not be reported`);
}
// Send protocol response
const response = {
type: "protocol",
version: this._protocolVersion,
target_module_uuid: this._targetModuleUuid,
passcode: this._passcode,
};
Log_1.default.debug(`[DebugClient] Sending protocol response: version=${this._protocolVersion}, target=${this._targetModuleUuid}, hasPasscode=${!!this._passcode}`);
this._sendMessage(response);
// Clear the handshake timeout since we received the protocol event
if (this._handshakeTimeoutId) {
clearTimeout(this._handshakeTimeoutId);
this._handshakeTimeoutId = undefined;
}
// Send a "resume" message to start stats flow
// This mimics what VS Code's configurationDoneRequest does
const resumeMessage = { type: "resume" };
Log_1.default.debug(`[DebugClient] Sending 'resume' to start stats streaming...`);
this._sendMessage(resumeMessage);
// Mark as connected
this._state = IMinecraftDebugProtocol_1.DebugConnectionState.Connected;
Log_1.default.message(`[Debug] Connected to Minecraft debugger (v${this._protocolVersion})`);
this._onProtocol.dispatch(this, event);
this._onConnected.dispatch(this, this.sessionInfo);
}
/**
* Handle statistics event.
*/
_handleStatEvent(event) {
this._lastStatTick = event.tick;
Log_1.default.verbose(`[DebugClient] StatEvent2 received: tick=${event.tick}, top-level stats: ${event.stats?.length || 0}`);
// Flatten the hierarchical stats into a flat list
const flatStats = [];
this._flattenStats(event.stats, event.tick, flatStats);
Log_1.default.verbose(`[DebugClient] StatEvent2 processed: tick ${event.tick}, ${flatStats.length} flattened stats, dispatching to ${this._onStats.count} subscribers`);
this._onStats.dispatch(this, { tick: event.tick, stats: flatStats });
}
/**
* Flatten hierarchical stats into a flat list.
*/
_flattenStats(stats, tick, output, parent) {
for (const stat of stats) {
const statId = stat.name.toLowerCase();
const statData = {
name: stat.name,
id: statId,
full_id: parent ? `${parent.full_id}_${statId}` : statId,
parent_name: parent?.name || "",
parent_id: parent?.id || "",
parent_full_id: parent?.full_id || "",
values: stat.values || [],
children_string_values: [],
should_aggregate: stat.should_aggregate,
tick: tick,
};
// If aggregating, collect child string values
if (stat.should_aggregate && stat.children) {
for (const child of stat.children) {
if (child.values && child.values.length > 0) {
if (typeof child.values[0] === "string" && child.values[0].length > 0) {
statData.children_string_values.push([child.name, child.values[0]]);
}
else if (typeof child.values[0] === "number") {
const valueStrings = child.values.map((v) => v.toString());
statData.children_string_values.push([child.name, ...valueStrings]);
}
}
}
}
output.push(statData);
// Recursively process children (if not aggregating)
if (!stat.should_aggregate && stat.children) {
this._flattenStats(stat.children, tick, output, statData);
}
}
}
/**
* Handle response messages.
*/
_handleResponse(response) {
const pending = this._pendingRequests.get(response.request_seq);
if (pending) {
this._pendingRequests.delete(response.request_seq);
if (response.success) {
pending.resolve(response.body);
}
else {
pending.reject(new Error(response.message || `Request ${pending.command} failed`));
}
}
}
/**
* Handle disconnect.
*/
_handleDisconnect(reason) {
const wasConnected = this._state === IMinecraftDebugProtocol_1.DebugConnectionState.Connected;
const wasConnecting = this._state === IMinecraftDebugProtocol_1.DebugConnectionState.Connecting;
this._state = IMinecraftDebugProtocol_1.DebugConnectionState.Disconnected;
this._errorMessage = reason;
// Clear handshake timeout if still pending
if (this._handshakeTimeoutId) {
clearTimeout(this._handshakeTimeoutId);
this._handshakeTimeoutId = undefined;
}
// Clear status check interval
if (this._statusCheckInterval) {
clearInterval(this._statusCheckInterval);
this._statusCheckInterval = undefined;
}
this._socket = undefined;
this._protocolVersion = IMinecraftDebugProtocol_1.ProtocolVersion.Unknown;
this._plugins = [];
this._capabilities = {
supportsCommands: false,
supportsProfiler: false,
supportsBreakpointsAsRequest: false,
};
// Reject all pending requests
for (const [seq, pending] of this._pendingRequests) {
pending.reject(new Error(`Disconnected: ${reason}`));
}
this._pendingRequests.clear();
if (wasConnected || wasConnecting) {
Log_1.default.message(`Debug client disconnected: ${reason}`);
this._onDisconnected.dispatch(this, reason);
}
}
}
exports.default = MinecraftDebugClient;