UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

582 lines (581 loc) 25.4 kB
"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;