UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

1,077 lines 105 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MaxTimeToWaitForServerToStart = exports.DedicatedServerBackupStatus = exports.DedicatedServerStatus = void 0; /** * ARCHITECTURE DOCUMENTATION: DedicatedServer - Minecraft Server Process Manager * =============================================================================== * * DedicatedServer manages a single instance of Minecraft Bedrock Dedicated Server, * handling process lifecycle, command execution, content deployment, and world backups. * * ## Core Responsibilities * * 1. **Process Lifecycle**: Start, stop, and monitor bedrock_server.exe * 2. **Command Execution**: Send commands to the server via stdin * 3. **Output Parsing**: Parse server stdout for events (player join/leave, test results) * 4. **Content Deployment**: Deploy add-on packs to the running server * 5. **World Backup**: Backup world files during runtime using save hold/resume * 6. **Debug Client**: Connect to the Minecraft script debugger for profiling * * ## Server Folder Structure * * Each DedicatedServer instance operates in a runtime server folder: * * ``` * srv20260101120000/ (Runtime server folder) * ├─ bedrock_server.exe (Copied from source) * ├─ server.properties (Generated, configures ports, world name) * ├─ allowlist.json (Symlink to source) * ├─ permissions.json (Symlink to source) * ├─ config/ (Generated config folder) * │ ├─ default.json (Creator Tools server config) * │ └─ ... * ├─ behavior_packs/ (Junction to source vanilla packs) * ├─ resource_packs/ (Junction to source vanilla packs) * ├─ definitions/ (Junction to source definitions) * ├─ development_behavior_packs/ (Writable - deployed add-ons go here) * │ └─ my_addon_abc123_my_bp/ (Symlink to pack cache folder) * ├─ development_resource_packs/ (Writable - deployed add-ons go here) * │ └─ my_addon_abc123_my_rp/ (Symlink to pack cache folder) * └─ worlds/ * └─ defaultWorld/ (Writable - active world data) * ├─ level.dat (World metadata in NBT format) * ├─ levelname.txt (World display name) * ├─ world_behavior_packs.json (Active behavior packs for world) * ├─ world_resource_packs.json (Active resource packs for world) * └─ db/ (LevelDB world data) * ├─ CURRENT * ├─ MANIFEST-000001 * ├─ *.ldb (Immutable SSTable files) * └─ *.log (Write-ahead log) * ``` * * ## Startup Sequence * * 1. **Signature Verification**: On Windows, verify bedrock_server.exe is Microsoft-signed * 2. **Folder Setup**: Create development_*_packs and worlds/defaultWorld if needed * 3. **Config Generation**: Write server.properties with port, world name, settings * 4. **World Restoration**: Optionally restore from latest backup * 5. **Process Spawn**: Launch server with stdin/stdout capture * - Windows: spawn bedrock_server.exe directly * - Linux: spawn bedrock_server with LD_LIBRARY_PATH set * 6. **Ready Detection**: Parse stdout for "Server started" message * 7. **Debugger Connection**: Connect to script debugger on port 19144 * 8. **Position Polling**: Start polling for player positions * * ## Linux Compatibility * * The server supports both Windows and Linux with these platform-specific behaviors: * * - **Executable**: `bedrock_server.exe` on Windows, `bedrock_server` on Linux * - **Library Path**: On Linux, `LD_LIBRARY_PATH` is set to the server directory * - **Signature Check**: Authenticode verification is Windows-only (skipped on Linux) * - **Path Handling**: Uses platform-aware path delimiters throughout * * ## Restart Backoff Strategy * * If the server crashes unexpectedly, it will attempt to restart with exponential * backoff to avoid resource exhaustion: * * | Crash # | Delay Before Restart | * |---------|---------------------| * | 1 | 1 second | * | 2 | 2 seconds | * | 3 | 4 seconds | * | 4+ | Stop auto-restart | * * The crash counter resets after 60 seconds of stable operation. * * ## World Backup Strategy * * Backups are performed using the Bedrock server's safe backup protocol: * * ``` * Normal Operation Backup Sequence * │ │ * │ ┌───── save hold ─────────────► │ (1) Suspend world writes * │ │ │ * │ │ save query ─────────────► │ (2) Get list of modified files * │ │ │ * │ │ ◄──── file list ──────────── │ (3) Server returns file paths & sizes * │ │ │ * │ │ [copy files] ───────────► │ (4) Copy only modified files * │ │ │ * │ │ save resume ────────────► │ (5) Resume world writes * │ │ │ * │ └─────────────────────────────► │ * ``` * * Backup folders are stored in a configurable location (default: user's Documents): * ``` * Documents/mctools/worlds/ * └─ world/ * ├─ world20260101120000/ (Timestamped backup) * │ ├─ files.json (File listing with sizes) * │ ├─ level.dat * │ └─ db/ * │ └─ <only modified .ldb files> * └─ world20260101130000/ (Later backup) * ``` * * **Incremental Backup Optimization**: * - The backup system tracks SHA hashes of LevelDB files * - Only files that have changed since the last backup are copied * - This is especially efficient for LevelDB's immutable SSTable (.ldb) files * - The `backupWorldFileListings` in ServerManager tracks known files across backups * * **Backup Timeout Protection**: * - A 60-second timeout prevents backup from getting stuck if server doesn't respond * - If timeout fires, save is forcefully resumed and an error is logged * - Timeout is cleared when backup completes successfully or server stops * * ## Content Deployment Strategy (Feb 2026) * * Hot-reload is ENABLED for **script-only changes** on subsequent deploys. * The first deploy always restarts to register packs with the world. * * Decision logic (in `deploy()` method): * * 1. **First deploy** (`deployCount === 0`): Always restart — packs must be registered * 2. **Subsequent deploys** with server running: * a. Capture before/after thumbprints of behavior + resource packs * b. If resource pack files changed → restart (textures/models can't hot-reload) * c. If behavior pack changes are script-only (.js/.ts/.map, no deletions) → `/reload` * d. Otherwise → restart * 3. **Caller override**: `isReloadable=false` forces restart regardless * * The `MinecraftUtilities.isReloadableSetOfChanges()` function gates the decision: * it returns true only when ALL file diffs are `.js`, `.ts`, or `.map` with no deletions. * * When deploying add-on content: * * **Hot-Reload Path** (script-only changes, subsequent deploys): * 1. **Sync Files**: Copy new/modified files to development_*_packs folders * 2. **Thumbprint Diff**: Compare before/after to determine what changed * 3. **Run `/reload`**: Hot-reload scripts without server restart * * **Restart Path** (structural changes, first deploy, or caller override): * 1. **Sync Files**: Copy new/modified files to development_*_packs folders * 2. **Update Pack References**: Ensure world_behavior_packs.json has pack UUIDs * 3. **Stop Server**: Graceful shutdown with backup * 4. **Restart Server**: Fresh start picks up all changes * * ## Slot Sentinel File (ServerManager) * * Each slot folder contains a sentinel file (`slot_context.json`) that records: * - Source server version and path * - When the slot was provisioned * - Deployed pack UUIDs and versions * - World settings and experiments enabled * * On startup, ServerManager compares the current context against the sentinel: * - If context matches: Reuse existing slot (fast startup) * - If context differs: Backup world, rebuild slot, restore world, re-deploy * * ## Server Output Parsing * * The server's stdout is continuously parsed for significant events: * * | Log Message Pattern | Action | * |-----------------------------|-------------------------------------------| * | "Server started" | Mark as running, enable debug, poll positions | * | "Player connected" | Extract player name/xuid, emit event | * | "Player disconnected" | Extract player name/xuid, emit event | * | "Data saved" | Backup sequence state machine trigger | * | "Changes to the level are resumed" | Backup complete notification | * | "Loaded test: ..." | GameTest started event | * | "passed test: ..." | GameTest passed event | * | "failed test: ..." | GameTest failed event | * * ## Script Debugger Integration * * After server start, DedicatedServer can connect to the Minecraft script debugger: * * 1. Send `script debugger listen 19144` command to server (if enableDebugger=true) * 2. Wait for "Debugger listening" message from Minecraft (confirms listener is ready) * 3. Connect MinecraftDebugClient to localhost:19144 (if enableDebuggerStreaming=true) * 4. Receive profiling stats (tick timing, script execution times) * 5. Forward debug events to ServerManager → HttpServer → WebSocket clients * * **Configuration (Jan 2026 Update)**: * - `enableDebugger` (default: true): Whether BDS enables script debugger on port 19144 * - `enableDebuggerStreaming` (default: true for serve command): Whether we connect and stream debug stats * The serve command enables streaming by default to provide real-time stats in the web UI. * * **Debug Client Connection Flow**: * - 3 seconds after "Server started" message: send `script debugger listen` command * - Wait for "Debugger listening" message from Minecraft stdout (or 10s timeout) * - Connect MinecraftDebugClient to localhost:debugPort * - Uses TCP keep-alive (30s) to detect dead connections * - Retries up to 5 times with exponential backoff on connection failure * - Has 10-second handshake timeout if server doesn't respond with ProtocolEvent * - Session info exposed via HTTP API: /api/{slot}/status includes debugConnectionState * * ## Related Files * * - ServerManager.ts: Creates and orchestrates DedicatedServer instances * - ServerConfigManager.ts: Manages server config/*.json files * - ServerPropertiesManager.ts: Manages server.properties file * - MCWorld.ts: World metadata parsing and modification * - MinecraftDebugClient.ts: Script debugger WebSocket client * - Thumbprint.ts: File tree hashing for change detection * * ## Key Methods * * - `startServer()`: Launch the server process with config * - `stopServer()`: Gracefully stop the server with "stop" command * - `deploy()`: Deploy add-on content to the running server * - `doBackup()`: Perform incremental world backup * - `runCommand()`: Execute a slash command on the server * - `ensureWorld()`: Set up world with settings and templates * * ## State Machine * * ``` * stopped ──► deploying ──► launching ──► starting ──► started * ▲ │ * └────────────────────── stopping ◄───────────────────┘ * ``` */ const stringio_1 = require("@rauschma/stringio"); const child_process_1 = require("child_process"); const ste_events_1 = require("ste-events"); const Player_1 = __importDefault(require("./../minecraft/Player")); const ServerConfigManager_1 = __importDefault(require("./ServerConfigManager")); const SecurityUtilities_1 = __importDefault(require("../core/SecurityUtilities")); const ServerMessage_1 = __importStar(require("./ServerMessage")); const ServerPropertiesManager_1 = __importDefault(require("../minecraft/ServerPropertiesManager")); const NodeStorage_1 = __importDefault(require("./NodeStorage")); const Log_1 = __importDefault(require("../core/Log")); const fs = __importStar(require("fs")); const os = __importStar(require("os")); const Project_1 = __importDefault(require("../app/Project")); const StorageUtilities_1 = __importDefault(require("../storage/StorageUtilities")); const LocalUtilities_1 = __importDefault(require("./LocalUtilities")); const MCWorld_1 = __importDefault(require("../minecraft/MCWorld")); const MinecraftUtilities_1 = __importDefault(require("../minecraft/MinecraftUtilities")); const Thumbprint_1 = __importDefault(require("../storage/Thumbprint")); const ICreatorToolsData_1 = require("../app/ICreatorToolsData"); const Utilities_1 = __importDefault(require("../core/Utilities")); const timers_1 = require("timers"); const IWorldSettings_1 = require("../minecraft/IWorldSettings"); const NodeFile_1 = __importDefault(require("./NodeFile")); const ZipStorage_1 = __importDefault(require("../storage/ZipStorage")); const MinecraftDebugClient_1 = __importDefault(require("../debugger/MinecraftDebugClient")); const IWorldBackupData_1 = require("./IWorldBackupData"); var DedicatedServerStatus; (function (DedicatedServerStatus) { DedicatedServerStatus[DedicatedServerStatus["stopped"] = 1] = "stopped"; DedicatedServerStatus[DedicatedServerStatus["deploying"] = 2] = "deploying"; DedicatedServerStatus[DedicatedServerStatus["launching"] = 3] = "launching"; DedicatedServerStatus[DedicatedServerStatus["starting"] = 4] = "starting"; DedicatedServerStatus[DedicatedServerStatus["started"] = 5] = "started"; })(DedicatedServerStatus || (exports.DedicatedServerStatus = DedicatedServerStatus = {})); var DedicatedServerBackupStatus; (function (DedicatedServerBackupStatus) { DedicatedServerBackupStatus[DedicatedServerBackupStatus["none"] = 0] = "none"; DedicatedServerBackupStatus[DedicatedServerBackupStatus["suspendingSaveCommandIssued"] = 1] = "suspendingSaveCommandIssued"; DedicatedServerBackupStatus[DedicatedServerBackupStatus["suspendingQueryCommandIssued"] = 2] = "suspendingQueryCommandIssued"; DedicatedServerBackupStatus[DedicatedServerBackupStatus["suspendingQueryResultsPending"] = 3] = "suspendingQueryResultsPending"; DedicatedServerBackupStatus[DedicatedServerBackupStatus["saveSuspended"] = 4] = "saveSuspended"; DedicatedServerBackupStatus[DedicatedServerBackupStatus["copyingFiles"] = 5] = "copyingFiles"; DedicatedServerBackupStatus[DedicatedServerBackupStatus["resumingSave"] = 6] = "resumingSave"; DedicatedServerBackupStatus[DedicatedServerBackupStatus["saveResumed"] = 7] = "saveResumed"; })(DedicatedServerBackupStatus || (exports.DedicatedServerBackupStatus = DedicatedServerBackupStatus = {})); exports.MaxTimeToWaitForServerToStart = 5000; // in ticks of 5ms each = 25 seconds // Player position polling interval in ms (5 seconds) const PLAYER_POSITION_POLL_INTERVAL = 5000; // Minimum distance (in blocks) to consider a "major" move worth reporting const PLAYER_MOVE_THRESHOLD = 2; class DedicatedServer { #pendingCommands = []; #pendingRequestIds = []; #pendingCommandsInternal = []; // Track which commands are internal (don't log) #worldBackupContainerFolder; serverPath; name; version; #backupInterval; #backupTimeoutTimer; // Timeout to prevent backup from getting stuck #playerPositionPollInterval; #lastPlayerPositions = new Map(); updates = []; #unexpectedStopLog = []; #backupStatus = DedicatedServerBackupStatus.none; #behaviorPacksStorage; #defaultWorldStorage; #resourcePacksStorage; #activeStdIn = null; #env; #currentCommandId = 0; #dsm; #starts = 0; #lastResult; startConfigurationHash = undefined; #port; #activeProcess = null; #status = DedicatedServerStatus.stopped; // Debug client for connecting to the Minecraft script debugger #debugClient; // enableDebugger: Whether BDS enables script debugger listening // The debug port is calculated dynamically based on the slot (base port + 12) #enableDebugger = true; // enableDebuggerStreaming: Whether we connect to the debug port and stream stats to web console // Enabled by default. Set worldSettings.enableDebuggerStreaming=false to disable. #enableDebuggerStreaming = true; // Flag to track when we're waiting for the debug listener to be ready // Set to true after sending 'script debugger listen', cleared when we receive 'Debugger listening' message #awaitingDebuggerListening = false; // Flag to track if the debug listener is ready but we haven't connected yet // Used when delaying debug client connection until a player joins #debugListenerReady = false; // Whether to delay debug client connection until a player joins // Note: This was used during debugging but the real fix was sending 'resume' after // protocol handshake (see MinecraftDebugClient._handleProtocolEvent) #delayDebugClientUntilPlayerJoins = false; // Track if at least one player has joined since server start // Used to know when it's safe to start debug streaming #hasPlayerJoined = false; // Whether to launch BDS in Minecraft Editor mode (passes Editor=true arg) #editorMode = false; // Associated managed world ID for the new backup system. // If set, backups will be stored in the WorldBackupManager structure. // If not set, backups use the legacy per-slot backup folder structure. #managedWorldId; // Last backup result for tracking what was backed up #lastBackupResult; outputLines = []; #opList; #gameTest; get opList() { return this.#opList; } set opList(newOps) { this.#opList = newOps; } get port() { return this.#port; } /** * Get the script debugger port for this server instance. * The debug port is the base port + 12, ensuring each slot has a unique debug port. * Slot 0: port 19132 -> debug port 19144 * Slot 1: port 19164 -> debug port 19176 * etc. */ get debugPort() { const basePort = this.#port ?? 19132; return basePort + 12; // Debug port offset is 12 from base port } get lastResult() { return this.#lastResult; } set port(newPort) { this.#port = newPort; } get editorMode() { return this.#editorMode; } set editorMode(value) { this.#editorMode = value; } get defaultWorldFolder() { if (!this.#defaultWorldStorage) { return undefined; } return this.#defaultWorldStorage.rootFolder; } get behaviorPacksFolder() { if (!this.#behaviorPacksStorage) { return undefined; } return this.#behaviorPacksStorage.rootFolder; } get resourcePacksFolder() { if (!this.#resourcePacksStorage) { return undefined; } return this.#resourcePacksStorage.rootFolder; } /** * Get the behavior packs storage (NodeStorage) for file watching purposes. */ get behaviorPacksStorage() { return this.#behaviorPacksStorage; } /** * Get the default world storage (NodeStorage) for file watching purposes. */ get defaultWorldStorage() { return this.#defaultWorldStorage; } /** * Get the resource packs storage (NodeStorage) for file watching purposes. */ get resourcePacksStorage() { return this.#resourcePacksStorage; } config; properties; deployCount = 0; #onServerOutput = new ste_events_1.EventDispatcher(); #onServerStarted = new ste_events_1.EventDispatcher(); #onServerRefreshed = new ste_events_1.EventDispatcher(); #onServerError = new ste_events_1.EventDispatcher(); #onServerStarting = new ste_events_1.EventDispatcher(); #onServerStopping = new ste_events_1.EventDispatcher(); #onServerStopped = new ste_events_1.EventDispatcher(); #onServerGameEvent = new ste_events_1.EventDispatcher(); #onPlayerConnected = new ste_events_1.EventDispatcher(); #onPlayerDisconnected = new ste_events_1.EventDispatcher(); #onTestStarted = new ste_events_1.EventDispatcher(); #onTestFailed = new ste_events_1.EventDispatcher(); #onTestSucceeded = new ste_events_1.EventDispatcher(); // Debug client events #onDebugConnected = new ste_events_1.EventDispatcher(); #onDebugDisconnected = new ste_events_1.EventDispatcher(); #onDebugStats = new ste_events_1.EventDispatcher(); #onDebugPaused = new ste_events_1.EventDispatcher(); #onDebugResumed = new ste_events_1.EventDispatcher(); #onProfilerCapture = new ste_events_1.EventDispatcher(); #updateIds = {}; constructor(name, dsm, env, serverPath, worldBackupContainerFolder) { this.name = name; this.serverPath = serverPath; this.#worldBackupContainerFolder = worldBackupContainerFolder; this.#dsm = dsm; this.#env = env; this.config = new ServerConfigManager_1.default(); this.config.ensureDefaultConfig(); this.config.addCartoConfig(); this.properties = new ServerPropertiesManager_1.default(); this.handleClose = this.handleClose.bind(this); this.doRunningBackup = this.doRunningBackup.bind(this); this.startServer = this.startServer.bind(this); this.stopServer = this.stopServer.bind(this); this.directOutput = this.directOutput.bind(this); this.handleCommandRequest = this.handleCommandRequest.bind(this); } get worldStoragePath() { return (NodeStorage_1.default.ensureEndsWithDelimiter(this.serverPath) + "worlds" + NodeStorage_1.default.platformFolderDelimiter + "defaultWorld" + NodeStorage_1.default.platformFolderDelimiter); } pushUpdates(additionalUpdates) { for (let i = 0; i < additionalUpdates.length; i++) { const update = additionalUpdates[i]; const updateId = update.eventId; if (!updateId || !this.#updateIds[updateId]) { if (updateId) { this.#updateIds[updateId] = true; } this.#onServerGameEvent.dispatch(this, update); this.updates.push(update); } } } get gameTest() { return this.#gameTest; } set gameTest(newGameTest) { this.#gameTest = newGameTest; } get status() { return this.#status; } get onServerStarting() { return this.#onServerStarting.asEvent(); } get onServerStopping() { return this.#onServerStopping.asEvent(); } get onServerStopped() { return this.#onServerStopped.asEvent(); } get onServerRefreshed() { return this.#onServerRefreshed.asEvent(); } get onServerOutput() { return this.#onServerOutput.asEvent(); } get onServerGameEvent() { return this.#onServerGameEvent.asEvent(); } get onServerError() { return this.#onServerError.asEvent(); } get onServerStarted() { return this.#onServerStarted.asEvent(); } get onTestStarted() { return this.#onTestStarted.asEvent(); } get onTestFailed() { return this.#onTestFailed.asEvent(); } get onTestSucceeded() { return this.#onTestSucceeded.asEvent(); } get onPlayerConnected() { return this.#onPlayerConnected.asEvent(); } get onPlayerDisconnected() { return this.#onPlayerDisconnected.asEvent(); } get onDebugConnected() { return this.#onDebugConnected.asEvent(); } get onDebugDisconnected() { return this.#onDebugDisconnected.asEvent(); } get onDebugStats() { return this.#onDebugStats.asEvent(); } get onDebugPaused() { return this.#onDebugPaused.asEvent(); } get onDebugResumed() { return this.#onDebugResumed.asEvent(); } get onProfilerCapture() { return this.#onProfilerCapture.asEvent(); } get debugClient() { return this.#debugClient; } get debuggerEnabled() { return this.#enableDebugger; } set debuggerEnabled(value) { this.#enableDebugger = value; } get debuggerStreamingEnabled() { return this.#enableDebuggerStreaming; } set debuggerStreamingEnabled(value) { this.#enableDebuggerStreaming = value; } /** * Get the managed world ID for this server. * If set, backups will use the WorldBackupManager system. */ get managedWorldId() { return this.#managedWorldId; } /** * Set the managed world ID for this server. * @param worldId The world ID from WorldBackupManager, or undefined to use legacy backups */ set managedWorldId(worldId) { this.#managedWorldId = worldId; } /** * Get the last backup result. */ get lastBackupResult() { return this.#lastBackupResult; } handleCommandRequest(event, data) { const slargs = Utilities_1.default.splitUntil(data, "|", 1); this.runCommand(slargs[1], slargs[0]); } async runActionSet(actionSet, requestId) { if (!requestId) { requestId = ""; } const actionData = JSON.stringify(actionSet); await this.runCommand("mct:runactions " + actionData, requestId); } async waitUntilStarted() { let waitTicks = 0; while (this.status !== DedicatedServerStatus.started && this.status !== DedicatedServerStatus.stopped && waitTicks < exports.MaxTimeToWaitForServerToStart) { await Utilities_1.default.sleep(5); waitTicks++; } if (waitTicks >= exports.MaxTimeToWaitForServerToStart) { Log_1.default.message("Timed out waiting for server to start."); } } async runCommandImmediate(command, tokenId, maxWaitMs) { Log_1.default.message("Running command: " + command); let targetResultLine = this.outputLines.length; await this.writeToServer(command); // maxPolls / pollCount count 5 ms polling iterations, not Minecraft game ticks. const maxPolls = maxWaitMs ? Math.ceil(maxWaitMs / 5) : 500; let pollCount = 0; if (tokenId) { let foundLineIndex = -1; while (foundLineIndex < 0 && pollCount < maxPolls) { await Utilities_1.default.sleep(5); for (let i = targetResultLine; i < this.outputLines.length; i++) { if (this.outputLines[i].message.indexOf(tokenId) >= 0) { foundLineIndex = i; } } pollCount++; } if (foundLineIndex >= 0) { let result = this.outputLines[foundLineIndex].message; Log_1.default.message("Command run complete: " + command + "| Result: " + result); return result; } } else { while (targetResultLine >= this.outputLines.length && pollCount < maxPolls) { await Utilities_1.default.sleep(5); pollCount++; } if (this.outputLines.length > targetResultLine) { let result = this.outputLines[targetResultLine].message; Log_1.default.message("Command run complete: " + command + "| Result: " + result); return result; } } return undefined; } async runCommand(command, requestId, isInternal) { if (!requestId) { requestId = ""; } const newCommand = this.#pendingCommands.length; this.#pendingCommands[newCommand] = command; this.#pendingRequestIds[newCommand] = requestId; this.#pendingCommandsInternal[newCommand] = isInternal === true; if (newCommand === this.#currentCommandId) { await this.executeNextCommand(); } } /** * Run an internal command that doesn't show in logs. * Used for implementation details like querytarget polling. */ async runInternalCommand(command, requestId) { return this.runCommand(command, requestId, true); } async writeToServer(commandLine) { if (this.#activeStdIn === null) { Log_1.default.message("Could not find active stdin to run command '" + commandLine + "'."); return; } // Security: Sanitize command to prevent injection commandLine = SecurityUtilities_1.default.sanitizeCommand(commandLine); if (!SecurityUtilities_1.default.isCommandSafe(commandLine)) { Log_1.default.message("Command rejected as unsafe: " + commandLine); return; } await (0, stringio_1.streamWrite)(this.#activeStdIn, commandLine + "\n"); } async ensureServerFolders() { if (!this.#behaviorPacksStorage) { this.#behaviorPacksStorage = new NodeStorage_1.default(NodeStorage_1.default.ensureEndsWithDelimiter(this.serverPath) + "development_behavior_packs", ""); await this.#behaviorPacksStorage.rootFolder.ensureExists(); } if (!this.#resourcePacksStorage) { this.#resourcePacksStorage = new NodeStorage_1.default(NodeStorage_1.default.ensureEndsWithDelimiter(this.serverPath) + "development_resource_packs", ""); await this.#resourcePacksStorage.rootFolder.ensureExists(); } if (!this.#defaultWorldStorage) { this.#defaultWorldStorage = new NodeStorage_1.default(this.worldStoragePath, ""); await this.#defaultWorldStorage.rootFolder.ensureExists(); } } async restoreLatestBackupWorld() { // If we have a managed world ID and the WorldBackupManager is available, // use the new restore system. Otherwise, fall back to legacy restore. if (this.#managedWorldId && this.#dsm.worldBackupManager && this.#defaultWorldStorage) { return await this.restoreManagedWorld(); } // Legacy restore system const worldBackupContainerFolderExists = fs.existsSync(this.#worldBackupContainerFolder.fullPath); if (!worldBackupContainerFolderExists) { return false; } const folders = fs.readdirSync(this.#worldBackupContainerFolder.fullPath); let latestWorldName; let latestWorldDate = new Date(0, 0, 0); const operId = await this.#dsm.creatorTools.notifyOperationStarted("Restoring world from '" + this.#worldBackupContainerFolder.fullPath + "'"); for (const folder of folders) { if (folder.startsWith("world") && folder.length === 19) { const dateStr = folder.substring(5); if (Utilities_1.default.isNumeric(dateStr)) { const fullPath = NodeStorage_1.default.ensureEndsWithDelimiter(this.#worldBackupContainerFolder.fullPath) + folder + NodeStorage_1.default.platformFolderDelimiter; const filesJsonPath = fullPath + "files.json"; const filesJsonExists = fs.existsSync(filesJsonPath); const worldDate = Utilities_1.default.getDateFromStr(dateStr); if (filesJsonExists && worldDate.getTime() > latestWorldDate.getTime()) { latestWorldName = folder; latestWorldDate = worldDate; } } } } if (latestWorldName) { const lastBackupWorldFolder = this.#worldBackupContainerFolder.folders[latestWorldName]; if (lastBackupWorldFolder && this.#defaultWorldStorage) { await this.#dsm.creatorTools.notifyStatusUpdate("Restoring world '" + lastBackupWorldFolder.name + "'"); await lastBackupWorldFolder.copyContentsOut(this.#defaultWorldStorage.rootFolder); await this.#dsm.creatorTools.notifyOperationEnded(operId, "Completed restoring world '" + lastBackupWorldFolder.name + "'"); return true; } } await this.#dsm.creatorTools.notifyOperationEnded(operId, "Was not able to restore world."); return false; } /** * Restore the latest backup using the new WorldBackupManager system. */ async restoreManagedWorld() { if (!this.#managedWorldId || !this.#dsm.worldBackupManager || !this.#defaultWorldStorage) { throw new Error("Managed restore requires managedWorldId and WorldBackupManager"); } const world = this.#dsm.worldBackupManager.getWorld(this.#managedWorldId); if (!world) { Log_1.default.message(`No managed world found with ID ${this.#managedWorldId}`); return false; } await world.loadBackups(); if (world.backups.length === 0) { Log_1.default.message(`No backups found for world ${this.#managedWorldId}`); return false; } // Get the latest backup const latestBackup = world.backups[world.backups.length - 1]; const operId = await this.#dsm.creatorTools.notifyOperationStarted(`Restoring managed world '${world.friendlyName}' (${this.#managedWorldId})`); try { // Restore the latest backup await this.#dsm.worldBackupManager.restoreBackup(this.#managedWorldId, latestBackup.id, this.#defaultWorldStorage.rootFolder.fullPath); await this.#dsm.creatorTools.notifyOperationEnded(operId, `Completed restoring world '${world.friendlyName}'`); return true; } catch (error) { Log_1.default.error(`Failed to restore managed world: ${error.message}`); await this.#dsm.creatorTools.notifyOperationEnded(operId, `Failed to restore world: ${error.message}`); return false; } } async applyWorldSettings(mcworld, startInfo) { if (startInfo?.worldSettings && startInfo.worldSettings.packageReferences) { for (let i = 0; i < startInfo.worldSettings.packageReferences.length; i++) { const packRefSet = startInfo.worldSettings.packageReferences[i]; mcworld.ensurePackReferenceSet(packRefSet); if (packRefSet.resourcePackReferences.length > 0) { mcworld.deferredTechnicalPreviewExperiment = true; } if (packRefSet.behaviorPackReferences.length > 0) { mcworld.betaApisExperiment = true; } } } if (startInfo && startInfo.worldSettings) { // only apply world settings if the world has no world templates. if (startInfo.worldSettings.worldTemplateReferences === undefined || startInfo.worldSettings.worldTemplateReferences.length <= 0) { // console.log("Applying settings " + JSON.stringify(startInfo.worldSettings)); mcworld.applyWorldSettings(startInfo?.worldSettings); } } await mcworld.save(); } async getStorageFromPath(path) { if (!fs.existsSync(path)) { return undefined; } const content = await NodeStorage_1.default.createFromPath(path); if (content instanceof NodeFile_1.default && (path.endsWith(".mcpack") || path.endsWith(".mcaddon") || path.endsWith(".mcworld") || path.endsWith(".zip") || path.endsWith(".mcproject"))) { const zs = await ZipStorage_1.default.loadFromFile(content); return zs; } return undefined; } async ensureWorld(startInfo) { const worldServerStorage = new NodeStorage_1.default(this.worldStoragePath, ""); const worldSourcePath = startInfo?.worldSettings?.worldContentPath; if (worldSourcePath) { let folder = await NodeStorage_1.default.createFromPathIncludingZip(worldSourcePath); if (folder) { await StorageUtilities_1.default.syncFolderTo(folder, worldServerStorage.rootFolder, false, false, false); } } const mcworld = new MCWorld_1.default(); mcworld.folder = worldServerStorage.rootFolder; await mcworld.loadMetaFiles(false); await this.applyWorldSettings(mcworld, startInfo); } async ensureContentDeployed(startInfo) { if (startInfo?.additionalContentPath) { let folder = await NodeStorage_1.default.createFromPathIncludingZip(startInfo.additionalContentPath); if (folder) { await this.deploy(folder, false, false); } } } async startServer(restartIfAlreadyRunning, start) { if (start === undefined) { start = { worldSettings: this.#dsm.creatorTools.worldSettings, mode: ICreatorToolsData_1.DedicatedServerMode.auto, iagree: this.#env.iAgreeToTheMinecraftEndUserLicenseAgreementAndPrivacyStatementAtMinecraftDotNetSlashEula, }; } if (this.#status === DedicatedServerStatus.launching || this.#status === DedicatedServerStatus.started || this.#status === DedicatedServerStatus.starting) { if (restartIfAlreadyRunning) { await this.stopServer(); } else { return; } } let rootPath = this.serverPath; this.#onServerStarting.dispatch(this, ""); this.#status = DedicatedServerStatus.launching; const ns = new NodeStorage_1.default(rootPath, ""); if (start.worldSettings?.backupType === IWorldSettings_1.BackupType.every2Minutes) { this.#backupInterval = (0, timers_1.setInterval)(this.doRunningBackup, 120000); } else if (start.worldSettings?.backupType === IWorldSettings_1.BackupType.every5Minutes) { this.#backupInterval = (0, timers_1.setInterval)(this.doRunningBackup, 300000); } if (this.#starts === 0) { await this.ensureServerFolders(); this.properties.serverFolder = ns.rootFolder; this.properties.levelName = "defaultWorld"; this.properties.contentLogFileEnabled = true; if (this.#port) { this.properties.serverPort = this.#port; } if (start && start.worldSettings) { this.properties.applyFromWorldSettings(start.worldSettings); // Apply debugger settings from worldSettings if (start.worldSettings.enableDebugger !== undefined) { this.#enableDebugger = start.worldSettings.enableDebugger; } if (start.worldSettings.enableDebuggerStreaming !== undefined) { this.#enableDebuggerStreaming = start.worldSettings.enableDebuggerStreaming; } if (start.worldSettings.isEditor !== undefined) { this.#editorMode = start.worldSettings.isEditor; } } await this.properties.writeFile(); const configFolder = ns.rootFolder.ensureFolder("config"); await configFolder.ensureExists(); this.config.serverConfigFolder = configFolder; this.config.writeFiles(); } // Use platform-aware path delimiter instead of hardcoded backslash rootPath = NodeStorage_1.default.ensureEndsWithDelimiter(rootPath); this.#env.utilities.validateFolderPath(rootPath); // Use platform-specific executable name const executableName = os.platform() === "win32" ? "bedrock_server.exe" : "bedrock_server"; const fullPath = rootPath + executableName; // Verify the executable exists if (!fs.existsSync(fullPath)) { const errorMsg = `Server executable not found at ${fullPath}`; Log_1.default.fail(errorMsg); this.#status = DedicatedServerStatus.stopped; this.#onServerError.dispatch(this, errorMsg); return; } // Verify digital signature on Windows before starting the server if (os.platform() === "win32" && !start?.unsafeSkipSignatureValidation) { Log_1.default.message("Verifying digital signature of " + fullPath + "..."); const sigResult = await LocalUtilities_1.default.verifyAuthenticodeSignature(fullPath); if (!sigResult.isValid) { const errorMsg = `Digital signature verification failed for ${fullPath}. ` + `Status: ${sigResult.status}. ${sigResult.error || ""}\n` + `This could indicate the file has been tampered with or corrupted.\n` + `If you trust this file, you can skip signature verification with --unsafe-skip-signature-validation.`; Log_1.default.fail(errorMsg); this.#status = DedicatedServerStatus.stopped; this.#onServerError.dispatch(this, errorMsg); return; } if (!sigResult.isMicrosoftSigned) { const errorMsg = `Digital signature verification: ${fullPath} is signed, but not by Microsoft/Mojang. ` + `Signer: ${sigResult.signer || "unknown"}\n` + `This could indicate the file is not an official Minecraft Dedicated Server.\n` + `If you trust this file, you can skip signature verification with --unsafe-skip-signature-validation.`; Log_1.default.fail(errorMsg); this.#status = DedicatedServerStatus.stopped; this.#onServerError.dispatch(this, errorMsg); return; } Log_1.default.message(`Signature verified: ${sigResult.signer}`); } else if (start?.unsafeSkipSignatureValidation) { Log_1.default.message("WARNING: Skipping digital signature verification as requested. This is unsafe."); } Log_1.default.message("Starting server from " + fullPath); // Kill any stale bedrock_server processes that may hold file locks on this slot this._killStaleProcesses(rootPath); // Set up spawn options - on Linux, we need LD_LIBRARY_PATH to find shared libraries const spawnOptions = { cwd: rootPath, }; if (os.platform() !== "win32") { spawnOptions.env = { ...process.env, LD_LIBRARY_PATH: rootPath, }; } const args = []; if (this.#editorMode) { args.push("Editor=true"); } const childProcess = (0, child_process_1.spawn)(fullPath, args, spawnOptions); this.#status = DedicatedServerStatus.starting; childProcess.on("close", this.handleClose); this.#activeStdIn = childProcess.stdin; this.#activeProcess = childProcess; // Write PID file so we can find stale processes after a crash if (childProcess.pid) { this._writePidFile(rootPath, childProcess.pid); } this.directOutput(childProcess.stdout); this.directErrors(childProcess.stderr); Log_1.default.verbose("Server '" + this.name + "' at '" + fullPath + "' launched" + (this.#editorMode ? " (Editor mode)" : "") + " (starts: " + this.#starts + ")."); } async executeNextCommand() { if (this.#currentCommandId < this.#pendingCommands.length) { this.#currentCommandId++; const nextCommand = this.#currentCommandId - 1; const commandLine = this.#pendingCommands[nextCommand]; const isInternal = this.#pendingCommandsInternal[nextCommand]; // Only log non-internal commands if (!isInternal) { Log_1.default.message("Command " + this.#currentCommandId + " sent:" + commandLine); } await this.writeToServer(commandLine); await this.executeNextCommand(); } } async doRunningBackup() { if (this.#backupStatus === DedicatedServerBackupStatus.none || this.#backupStatus === DedicatedServerBackupStatus.saveResumed) { this.#backupStatus = DedicatedServerBackupStatus.suspendingSaveCommandIssued; // Set a timeout to prevent backup from getting stuck if server doesn't respond // If backup doesn't complete within 60 seconds, force resume save if (this.#backupTimeoutTimer) { (0, timers_1.clearTimeout)(this.#backupTimeoutTimer); } this.#backupTimeoutTimer = setTimeout(async () => { if (this.#backupStatus !== DedicatedServerBackupStatus.none && this.#backupStatus !== DedicatedServerBackupStatus.saveResumed) { Log_1.default.error("Backup timed out after 60 seconds - forcing save resume"); this.#backupStatus = DedicatedServerBackupStatus.none; await this.runCommand("save resume"); } }, 60000); await this.runCommand("save hold"); } } async doBackup(backupFileLine) { // If we have a managed world ID and the WorldBackupManager is available, // use the new backup system. Otherwise, fall back to legacy backup. if (this.#managedWorldId && this.#dsm.worldBackupManager && this.#defaultWorldStorage) { await this.doManagedBackup(backupFileLine); return; } // Legacy backup system const worldPath = "world" + Utilities_1.default.getDateStr(new Date()); const backupFolder = this.#worldBackupContainerFolder.ensureFolder(worldPath); await backupFolder.ensureExists(); const inclusionList = []; if (backupFileLine) { const items = backupFileLine.split(", "); for (let i = 0; i < items.length; i++) { const fileItem = items[i].split(":"); if (fileItem.length === 2) { let size = undefined; let path = fileItem[0]; const firstSlash = path.indexOf("/"); if (firstSlash > 0) { path = path.substring(firstSlash + 1); } try { size = parseInt(fileItem[1]); } catch (e) { Log_1.default.verbose("Failed to parse backup file size: " + e); } if (size !== undefined && firstSlash > 0) { inclusionList.push({ path: path, size: size }); } } } } if (this.#defaultWorldStorage) { Log_1.default.message("Backing world up to '" + this.#defaultWorldStorage.rootFolder.fullPath + "' to '" + backupFolder.fullPath + "'"); await this.#defaultWorldStorage.rootFolder.copyContentsTo(backupFolder.fullPath, inclusionList, backupFileLine === undefined, this.#dsm.backupWorldFileListings, StorageUtilities_1.default.ensureStartsWithDelimiter(StorageUtilities_1.default.ensureEndsWithDelimiter(this.#worldBackupContainerFolder.name + StorageUtilities_1.default.standardFolderDelimiter + worldPath))); } if (inclusionList) { await backupFolder.saveFilesList(worldPath, inclusionList); } } /** * Create a backup using the new WorldBackupManager system. * This provides better organization, deduplication, and export capabilities. */ async doManagedBackup(backupFileLine) { if (!this.#managedWorldId