UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

1,192 lines 173 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 }); /** * ARCHITECTURE DOCUMENTATION: HttpServer - Web Server and Real-Time Notification Hub * =================================================================================== * * HttpServer provides the HTTP/HTTPS server for Minecraft Creator Tools and serves as * a central hub for real-time client notifications via WebSocket connections. It exposes * REST APIs for server management and proxies events from DedicatedServer instances. * * ## Core Responsibilities * * 1. **HTTP Server**: Serves static files, API endpoints, and the web application * 2. **WebSocket Notifications**: Broadcasts real-time events to connected clients * 3. **Storage Watching**: Monitors NodeStorage for file changes and broadcasts updates * 4. **Authentication**: Manages session tokens and permission levels * 5. **MCP Server Integration**: Hosts Model Context Protocol server * 6. **Server Management API**: REST endpoints for controlling DedicatedServer instances * * ## API Architecture * * The HTTP server exposes REST endpoints for remote server management: * * ``` * /api/auth - Authentication with passcode * /api/<slot>/start - Start a DedicatedServer on the specified slot * /api/<slot>/stop - Stop the server on the specified slot * /api/<slot>/status - Get server status and recent log messages * /api/<slot>/command - Send a slash command to the server * /api/<slot>/deploy - Deploy add-on content to the server * /api/content/<path> - Access files when in view/edit mode * /api/validate - Validate Minecraft content * /api/shutdown - Graceful shutdown (view mode only) * /api/acceptEula - Accept Minecraft EULA (admin only, enables BDS features) * /api/eulaStatus - Check if EULA has been accepted * /api/commands - List available ToolCommands * /api/commands/<cmd> - Execute a ToolCommand (POST with args/flags JSON body) * ``` * * ## Real-Time Sync Architecture * * HttpServer sits between server-side file changes and client-side updates: * * ``` * NodeStorage (fs.watch) ──→ HttpServer ──→ WebSocket ──→ HttpStorage (client) * │ │ │ * └── IStorageChangeEvent ────┘ │ * │ │ * IServerNotification ─────────────────→ onFileUpdated event * │ * MCWorld ─→ WorldView * ``` * * ## WebSocket Event Types * * The WebSocket connection broadcasts various event types to clients: * * | Event Name | Description | * |--------------------|-------------------------------------------------| * | statusUpdate | Server status changed (starting, started, etc.)| * | playerJoined | Player connected to the Minecraft server | * | playerLeft | Player disconnected from the server | * | playerMoved | Player position changed (from position polling) | * | storage/change | File in watched storage was modified | * | debugConnected | Script debugger connection established | * | debugStats | Profiling statistics from script debugger | * | gameEvent | Generic game event from server | * | serverShutdown | MCT server is shutting down (sent before close) | * * ## Storage Watcher Integration * * The server maintains watchers for NodeStorage instances and converts storage events * to WebSocket notifications: * * - **startWatchingStorage()**: Registers a storage for watching with a unique slot ID * - **stopWatchingStorage()**: Stops watching a specific storage * - **stopAllStorageWatchers()**: Cleanup when server stops * - **_handleStorageChange()**: Converts IStorageChangeEvent to IServerNotification * * ## Authentication & Permission Levels * * Four permission levels control access to different features: * * | Level | Access | * |--------------------|-------------------------------------------------| * | displayReadOnly | View server status and logs | * | fullReadOnly | Above + file browsing | * | updateState | Above + start/stop servers, deploy content | * | admin | Full access including shutdown | * * Passcodes are set via command line and validated using encrypted tokens. * * ## SSL/TLS Support (Experimental) * * HttpServer supports experimental HTTPS via command-line configuration: * - Certificate and key files specified at startup * - Not persisted to disk for security * - Optional HTTPS-only mode * * ## Integration with ServerManager * * HttpServer receives events from ServerManager and converts them to notifications: * * ``` * DedicatedServer ──► ServerManager ──► HttpServer ──► WebSocket Clients * │ │ │ * │ │ bubbleServerStarted * │ │ │ * │ │ └─► notify({ eventName: 'statusUpdate', ... }) * │ │ * │ │ bubblePlayerConnected * │ │ │ * │ │ └─► notify({ eventName: 'playerJoined', ... }) * ``` * * ## Related Files * * - ServerManager.ts: Creates HttpServer and forwards server events * - IServerNotification.ts: Notification message types * - NodeStorage.ts: Server-side file watching with fs.watch() * - HttpStorage.ts: Client-side notification receiver * - IStorageWatcher.ts: Interface definitions for watcher system * - MinecraftMcpServer.ts: MCP server for AI tool integration * * ## Key Methods * * - init(): Initialize HTTP/HTTPS server and WebSocket * - stop(): Cleanup resources including storage watchers * - notify(): Broadcast notification to all connected WebSocket clients * - notifyStatusUpdate(): Send server status change to a specific slot * - sendNotificationToSlot(): Send notification to clients subscribed to a slot * - startWatchingStorage(): Begin monitoring a NodeStorage instance * - processRequest(): Main HTTP request handler and router */ const http = __importStar(require("http")); const https = __importStar(require("https")); const fs = __importStar(require("fs")); const ws_1 = require("ws"); const uuid_1 = require("uuid"); const ServerManager_1 = require("./ServerManager"); const IWorldBackupData_1 = require("./IWorldBackupData"); const NodeStorage_1 = __importDefault(require("./NodeStorage")); const crypto = __importStar(require("crypto")); const IAuthenticationToken_1 = require("./IAuthenticationToken"); const Log_1 = __importDefault(require("../core/Log")); const ZipStorage_1 = __importDefault(require("../storage/ZipStorage")); const CreatorToolsHost_1 = __importDefault(require("../app/CreatorToolsHost")); const SecurityUtilities_1 = __importDefault(require("../core/SecurityUtilities")); // import { Http2ServerRequest, Http2ServerResponse } from "http2"; const Project_1 = __importDefault(require("../app/Project")); const IProjectInfoData_1 = require("../info/IProjectInfoData"); const ProjectInfoSet_1 = __importDefault(require("../info/ProjectInfoSet")); const ProjectInfoUtilities_1 = __importDefault(require("../info/ProjectInfoUtilities")); const MinecraftMcpServer_1 = __importDefault(require("./MinecraftMcpServer")); const HttpUtilities_1 = __importDefault(require("./HttpUtilities")); const Utilities_1 = __importDefault(require("../core/Utilities")); const HttpStorage_1 = __importDefault(require("../storage/HttpStorage")); const LocalUtilities_1 = __importDefault(require("./LocalUtilities")); const toolcommands_1 = require("../app/toolcommands"); const registerNodeCommands_1 = require("../app/toolcommands/registerNodeCommands"); class HttpServer { host = "localhost"; port = 80; creatorTools; headers = { "Access-Control-Allow-Origin": "http://localhost:6126", // Restrict to known origins "Access-Control-Allow-Methods": "OPTIONS, POST, GET", "Access-Control-Max-Age": 86400, // 24 hours instead of 30 days "Access-Control-Allow-Headers": "Content-Type, Authorization, mctpc", "Access-Control-Allow-Credentials": "true", }; _webStorage; _resStorage; _dataStorage; _distStorage; _schemasStorage; _formsStorage; _esbuildWasmStorage; _serverManager; _localEnvironment; _algorithm = "aes-256-gcm"; _httpsServer; _httpServer; _mcpServer; _pwdHash; // Track whether the server is listening and ready to accept connections _isListeningMetaFlag = false; _readyResolvers = []; /** * Returns true if the HTTP server is actually listening and accepting connections. * This checks the underlying server state, not just the flag. */ get isListening() { if (!this._isListeningMetaFlag) { return false; } // Check if the underlying server is still bound if (this._httpServer && this._httpServer.listening) { return true; } if (this._httpsServer && this._httpsServer.listening) { return true; } return false; } /** * Returns a promise that resolves when the server is ready to accept connections. * If the server is already listening, resolves immediately. * @param timeoutMs Optional timeout in milliseconds. If provided and the server * doesn't become ready in time, the promise rejects with a timeout error. */ waitForReady(timeoutMs) { if (this._isListeningMetaFlag) { return Promise.resolve(); } return new Promise((resolve, reject) => { let timer; const onReady = () => { if (timer !== undefined) { clearTimeout(timer); } resolve(); }; this._readyResolvers.push(onReady); if (timeoutMs !== undefined && timeoutMs > 0) { timer = setTimeout(() => { const idx = this._readyResolvers.indexOf(onReady); if (idx !== -1) { this._readyResolvers.splice(idx, 1); } reject(new Error(`HTTP server did not become ready within ${timeoutMs}ms`)); }, timeoutMs); } }); } /** * Called when the server starts listening to mark it as ready and resolve any pending waiters. */ _markAsListening() { this._isListeningMetaFlag = true; for (const resolve of this._readyResolvers) { resolve(); } this._readyResolvers = []; } // Experimental SSL configuration - passed via command line, not persisted _sslConfig; get sslConfig() { return this._sslConfig; } set sslConfig(config) { this._sslConfig = config; } // Temporary content registry for serving dynamic content (e.g., project geometry for rendering) _tempContent = new Map(); // Content storage for view mode - serves local files via /api/content _contentStorage; _contentPath; // View mode flag - when true, the server is running in "view" context (from `mct view` command) // This enables the /api/shutdown endpoint for graceful shutdown _isViewMode = false; // Edit mode flag - when true, the server allows write operations to content (from `mct edit` command) // This enables PUT/DELETE operations on /api/content endpoints _isEditMode = false; // When true, requires authentication for /mcp even from localhost. // Set via --mcp-require-auth CLI flag. Default: false (localhost bypasses auth for MCP). _mcpRequireAuth = false; // Promise to guard against concurrent MCP server initialization _mcpServerInitPromise; // WebSocket notification server for pushing updates to clients _wsServer; _wsClients = new Map(); // Storage watchers for file system change notifications // Maps storage instance to watcher ID for cleanup _storageWatchers = new Map(); // Maps slot numbers to their associated storages for event routing _slotStorages = new Map(); // Security: Files that should never be served (security-sensitive or potentially dangerous) static BLOCKED_FILE_NAMES = new Set([ "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", ".env", ".env.local", ".env.development", ".env.production", ".npmrc", ".yarnrc", "tsconfig.json", "webpack.config.js", "vite.config.js", "rollup.config.js", ".gitignore", ".gitattributes", "dockerfile", "docker-compose.yml", "docker-compose.yaml", "makefile", "cmakelists.txt", ".htaccess", "web.config", ]); // Security: File extensions that are safe to serve static SAFE_EXTENSIONS = new Set([ "json", "png", "jpg", "jpeg", "gif", "tga", "lang", "txt", "md", "mcfunction", "mcstructure", "mcworld", "mctemplate", "mcaddon", "mcpack", "material", "vertex", "geometry", "fragment", "nbt", "fsb", "ogg", "wav", "flac", "obj", "svg", // LevelDB files for world data "ldb", "log", ]); /** Whether local res/ storage includes the vanilla serve folder with PNG textures */ _hasLocalVanillaServe; constructor(localEnv, serverManager) { this._serverManager = serverManager; this._webStorage = new NodeStorage_1.default(this.getRootPath() + "web/", ""); this._resStorage = new NodeStorage_1.default(this.getResRootPath(), ""); this._dataStorage = serverManager.dataStorage; this._distStorage = new NodeStorage_1.default(this.getRootPath() + "dist/", ""); // Serve schemas and forms from @minecraft/bedrock-schemas package at runtime // instead of shipping copies in the build output. const bsRoot = LocalUtilities_1.default.bedrockSchemasRoot; if (bsRoot) { this._schemasStorage = new NodeStorage_1.default(bsRoot + "/schemas/", ""); this._formsStorage = new NodeStorage_1.default(bsRoot + "/forms/", ""); } else { this._schemasStorage = new NodeStorage_1.default(this.getRootPath() + "schemas/", ""); } // Serve esbuild-wasm from its npm package at runtime instead of shipping // a copy in the build output (~13 MB savings). esbuild-wasm is a declared // dependency of the jsn package so it's always available. try { const esbuildWasmDir = require.resolve("esbuild-wasm/esbuild.wasm").replace(/[\\/]esbuild\.wasm$/, "/"); this._esbuildWasmStorage = new NodeStorage_1.default(esbuildWasmDir, ""); } catch { // esbuild-wasm not installed — fall back to dist/ } // Check at init time if we have local vanilla serve textures. // When running from the app/ folder, public/res/ has a serve/ directory with // PNG-converted textures. The remote CDN (mctools.dev) may be missing some. const fs = require("fs"); const resRoot = this.getResRootPath(); this._hasLocalVanillaServe = fs.existsSync(resRoot + "latest/van/serve/resource_pack/textures/terrain_texture.json"); this._localEnvironment = localEnv; this.processRequest = this.processRequest.bind(this); } init() { const requestListener = this.processRequest; if (this._localEnvironment && this._localEnvironment.serverHostPort) { this.port = this._localEnvironment.serverHostPort; } if (this._localEnvironment && this._localEnvironment.serverDomainName) { this.host = this._localEnvironment.serverDomainName; } // Initialize HTTPS if experimental SSL config is provided if (this._sslConfig) { this.initHttps(requestListener); } // Initialize HTTP server (unless experimental SSL-only mode is enabled) if (!this._sslConfig?.httpsOnly) { this._httpServer = http.createServer(requestListener); // Bind to 127.0.0.1 when host is "localhost" to ensure IPv4 connectivity. // Node.js resolves "localhost" via the OS, which may return only ::1 (IPv6), // causing ECONNREFUSED for IPv4-only clients. const listenHost = this.host === "localhost" ? "127.0.0.1" : this.host; this._httpServer.listen(this.port, listenHost, () => { // Server started - mark as listening to unblock waiters this._markAsListening(); }); // Initialize WebSocket server for notifications this.initWebSocketServer(this._httpServer); } } /** * Initialize HTTPS server with experimental SSL configuration. * SSL config is passed via command line arguments - nothing is persisted. */ initHttps(requestListener) { if (!this._sslConfig) { return; } try { const httpsOptions = this.buildHttpsOptions(); const httpsPort = this._sslConfig.port ?? 443; this._httpsServer = https.createServer(httpsOptions, requestListener); const httpsListenHost = this.host === "localhost" ? "127.0.0.1" : this.host; this._httpsServer.listen(httpsPort, httpsListenHost, () => { Log_1.default.message(`(EXPERIMENTAL) Minecraft HTTPS server is running on https://${this.host}:${httpsPort}`); this._markAsListening(); }); } catch (error) { Log_1.default.fail(`Failed to initialize experimental HTTPS server: ${error}`); throw error; } } /** * Build HTTPS server options from experimental SSL configuration. */ buildHttpsOptions() { if (!this._sslConfig) { throw new Error("SSL config is required for HTTPS"); } const options = {}; if (this._sslConfig.pfxPath) { // PKCS12/PFX format if (!fs.existsSync(this._sslConfig.pfxPath)) { throw new Error(`Experimental SSL: PFX file not found: ${this._sslConfig.pfxPath}`); } options.pfx = fs.readFileSync(this._sslConfig.pfxPath); if (this._sslConfig.pfxPassphrase) { options.passphrase = this._sslConfig.pfxPassphrase; } } else if (this._sslConfig.certPath && this._sslConfig.keyPath) { // PEM format if (!fs.existsSync(this._sslConfig.certPath)) { throw new Error(`Experimental SSL: Certificate file not found: ${this._sslConfig.certPath}`); } if (!fs.existsSync(this._sslConfig.keyPath)) { throw new Error(`Experimental SSL: Key file not found: ${this._sslConfig.keyPath}`); } options.cert = fs.readFileSync(this._sslConfig.certPath); options.key = fs.readFileSync(this._sslConfig.keyPath); } else { throw new Error("Experimental SSL configuration requires either (--experimental-ssl-cert + --experimental-ssl-key) or --experimental-ssl-pfx"); } // Optional CA chain if (this._sslConfig.caPath) { if (!fs.existsSync(this._sslConfig.caPath)) { throw new Error(`Experimental SSL: CA certificate file not found: ${this._sslConfig.caPath}`); } options.ca = fs.readFileSync(this._sslConfig.caPath); } return options; } /** * Initialize WebSocket server for pushing notifications to clients. * The WebSocket server shares the HTTP server and handles upgrade requests to /ws/notifications. */ initWebSocketServer(httpServer) { this._wsServer = new ws_1.WebSocketServer({ noServer: true, maxPayload: 64 * 1024 * 1024 }); // Handle WebSocket connections this._wsServer.on("connection", (socket, req) => { this.handleWebSocketConnection(socket, req); }); // Handle HTTP upgrade requests for WebSocket httpServer.on("upgrade", (request, socket, head) => { const url = request.url || ""; Log_1.default.verbose("WebSocket upgrade request received"); // All WebSocket connections go through /ws/notifications. if (url.startsWith("/ws/notifications")) { // Authenticate the WebSocket connection const permissionLevel = this.authenticateWebSocketRequest(request); if (permissionLevel === undefined) { Log_1.default.debug("WebSocket upgrade rejected - authentication failed"); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } Log_1.default.verbose("WebSocket upgrade accepted"); this._wsServer.handleUpgrade(request, socket, head, (ws) => { // Store permission level on the request for later use request.permissionLevel = permissionLevel; this._wsServer.emit("connection", ws, request); }); } else { socket.destroy(); } }); } /** * Authenticate a WebSocket upgrade request. * Returns the permission level if authenticated, undefined otherwise. * * Requires a valid auth token via query string (?token=...) or mctauth cookie. * Localhost connections are NOT exempted — all WebSocket clients must authenticate. */ authenticateWebSocketRequest(req) { // Check for auth token in query string or cookie const url = new URL(req.url || "", `http://${req.headers.host}`); const tokenParam = url.searchParams.get("token"); if (tokenParam) { return this.validateToken(tokenParam, req); } // Check cookies const cookies = this.parseCookies(req); const authCookie = cookies["mctauth"]; if (authCookie) { return this.validateToken(authCookie, req); } return undefined; } /** * Handle a new WebSocket connection. */ handleWebSocketConnection(socket, req) { const clientId = (0, uuid_1.v4)(); const permissionLevel = req.permissionLevel; // Register the client this._wsClients.set(socket, { id: clientId, subscribedEvents: new Set(), permissionLevel: permissionLevel, }); Log_1.default.verbose(`WebSocket client connected: ${clientId}`); // Handle messages from client (subscriptions) socket.on("message", (data) => { this.handleWebSocketMessage(socket, data); }); // Handle client disconnect socket.on("close", () => { this._wsClients.delete(socket); Log_1.default.verbose(`WebSocket client disconnected: ${clientId}`); }); socket.on("error", (err) => { Log_1.default.debug(`WebSocket error for client ${clientId}: ${err.message}`); this._wsClients.delete(socket); }); } /** * Handle incoming WebSocket message (subscription requests). */ handleWebSocketMessage(socket, data) { try { const message = JSON.parse(data.toString()); Log_1.default.verbose(`[HttpServer] Received WebSocket message: ${data.toString().substring(0, 200)}`); if (message.header?.messageType === "subscriptionRequest") { const client = this._wsClients.get(socket); if (!client) return; const { eventNames, slot } = message.body; if (message.header.messagePurpose === "subscribe") { // Add subscriptions for (const eventName of eventNames) { client.subscribedEvents.add(eventName); } if (slot !== undefined) { client.slot = slot; } Log_1.default.verbose(`[HttpServer] Client ${client.id} subscribed to events: ${eventNames.join(", ")} for slot ${slot}`); } else if (message.header.messagePurpose === "unsubscribe") { // Remove subscriptions for (const eventName of eventNames) { client.subscribedEvents.delete(eventName); } } // Send response const response = { header: { version: 1, requestId: message.header.requestId, messageType: "subscriptionResponse", messagePurpose: "response", }, body: { success: true, subscribedEvents: Array.from(client.subscribedEvents), }, }; socket.send(JSON.stringify(response)); } } catch (e) { Log_1.default.debug("Error handling WebSocket message: " + e); } } /** * Validate an encrypted auth token and return the permission level. */ validateToken(encryptedToken, req) { if (!encryptedToken || encryptedToken.indexOf("|") < 0) { return undefined; } try { const tokenParts = Utilities_1.default.splitUntil(encryptedToken, "|", 2); if (tokenParts.length < 2) { return undefined; } const content = tokenParts[0]; const iv = tokenParts[1]; const authTag = tokenParts.length >= 3 ? tokenParts[2] : undefined; const decryptedStr = this.decrypt(iv, content, authTag); const decryptedContent = SecurityUtilities_1.default.sanitizeJsonObject(JSON.parse(decryptedStr)); if (decryptedContent.permissionLevel && decryptedContent.time) { // Verify fingerprint if present (for enhanced security) if (decryptedContent.fingerprint) { const userAgent = req.headers["user-agent"]; const clientIp = (req.socket?.remoteAddress || req.headers["x-forwarded-for"]); const fingerprint = this.generateFingerprint(userAgent, clientIp); if (fingerprint !== decryptedContent.fingerprint) { Log_1.default.debug(`Token validation: fingerprint mismatch (IP: ${clientIp})`); return undefined; } } return decryptedContent.permissionLevel; } } catch (e) { Log_1.default.debug("Token validation error: " + e); } return undefined; } /** * Broadcast a notification to all subscribed WebSocket clients. * @param notification The notification to broadcast */ broadcastNotification(notification) { const eventName = notification.body.eventName; const slot = notification.body.slot; let sentCount = 0; let skippedCount = 0; // Log debug stats at verbose level to reduce noise (fires ~10x/sec) if (eventName === "debugStats") { const body = notification.body; Log_1.default.verbose(`[HttpServer] Broadcasting debugStats: slot=${slot}, tick=${body.tick}, statsCount=${body.stats?.length || 0}, wsClients=${this._wsClients.size}`); } for (const [socket, client] of this._wsClients) { // Check if client is subscribed to this event if (!client.subscribedEvents.has(eventName)) { if (eventName === "debugStats") { Log_1.default.verbose(`[HttpServer] Client ${client.id} not subscribed to debugStats (subscribed: ${Array.from(client.subscribedEvents).join(", ")})`); } skippedCount++; continue; } // If client has a slot filter, check it matches if (client.slot !== undefined && slot !== undefined && client.slot !== slot) { if (eventName === "debugStats") { Log_1.default.verbose(`[HttpServer] Client ${client.id} slot mismatch: client=${client.slot}, event=${slot}`); } skippedCount++; continue; } try { socket.send(JSON.stringify(notification)); if (eventName === "debugStats") { Log_1.default.verbose(`[HttpServer] Sent debugStats to client ${client.id}`); } sentCount++; } catch (e) { Log_1.default.debug(`Error sending notification to client ${client.id}: ${e}`); } } // Only log broadcast stats at verbose level to reduce noise Log_1.default.verbose(`[HttpServer] Broadcast ${eventName} (slot ${slot}): sent to ${sentCount} clients, skipped ${skippedCount} (total ${this._wsClients.size})`); } /** * Create and broadcast a notification. * Helper method for common notification patterns. */ notify(body) { const notification = { header: { version: 1, requestId: (0, uuid_1.v4)(), messageType: "notification", messagePurpose: "event", }, body: body, }; this.broadcastNotification(notification); } /** * Notify clients of a file change in world content. */ notifyFileChange(eventName, slot, category, path) { this.notify({ eventName: eventName, timestamp: Date.now(), slot: slot, category: category, path: path, }); } /** * Notify clients of a server state change. */ notifyServerState(slot, state, message) { this.notify({ eventName: "serverStateChanged", timestamp: Date.now(), slot: slot, state: state, message: message, }); } /** * Notify clients of a player movement. */ notifyPlayerMoved(slot, playerName, position, rotation, dimension) { this.notify({ eventName: "playerMoved", timestamp: Date.now(), slot: slot, playerName: playerName, position: position, rotation: rotation, dimension: dimension, }); } /** * Forward a game event from Minecraft to WebSocket clients. */ notifyGameEvent(slot, minecraftEventName, data) { this.notify({ eventName: "gameEvent", timestamp: Date.now(), slot: slot, minecraftEventName: minecraftEventName, data: data, }); } /** * Notify clients of a full status update for a server slot. * This replaces the need for polling /api/{slot}/status/ * * @param slot The server slot number * @param status The current DedicatedServerStatus value * @param recentMessages Recent messages from the server * @param title Optional server title */ notifyStatusUpdate(slot, status, recentMessages, title) { this.notify({ eventName: "statusUpdate", timestamp: Date.now(), slot: slot, status: status, recentMessages: recentMessages, title: title, }); } /** * Start watching a NodeStorage for file changes and broadcast them via WebSocket. * This enables real-time synchronization of file changes to connected clients. * * @param storage The NodeStorage to watch * @param slot The server slot number this storage is associated with * @param category The category of content (behavior_packs, resource_packs, world) */ startWatchingStorage(storage, slot, category) { // Check if already watching this storage if (this._storageWatchers.has(storage)) { return; } // Start watching the storage const watcherId = storage.startWatching(); this._storageWatchers.set(storage, watcherId); // Track the slot association if (!this._slotStorages.has(slot)) { this._slotStorages.set(slot, []); } this._slotStorages.get(slot).push({ storage, category }); // Subscribe to storage change events storage.onStorageChange.subscribe((sender, event) => { this._handleStorageChange(slot, category, event); }); Log_1.default.verbose(`Started watching ${category} storage for slot ${slot}`); } /** * Stop watching a specific storage. */ stopWatchingStorage(storage) { const watcherId = this._storageWatchers.get(storage); if (watcherId) { storage.stopWatching(watcherId); this._storageWatchers.delete(storage); // Remove from slot associations for (const [slot, storages] of this._slotStorages) { const index = storages.findIndex((s) => s.storage === storage); if (index >= 0) { storages.splice(index, 1); if (storages.length === 0) { this._slotStorages.delete(slot); } break; } } } } /** * Stop watching all storages. */ stopAllStorageWatchers() { for (const [storage, watcherId] of this._storageWatchers) { storage.stopWatching(watcherId); } this._storageWatchers.clear(); this._slotStorages.clear(); Log_1.default.verbose("Stopped all storage watchers"); } /** * Handle a storage change event and broadcast it to WebSocket clients. */ _handleStorageChange(slot, category, event) { // Map storage change type to notification event name let eventName; if (event.isFile) { switch (event.changeType) { case "added": eventName = "fileAdded"; break; case "removed": eventName = "fileRemoved"; break; default: eventName = "fileChanged"; } } else { eventName = "folderChanged"; } // Broadcast the notification this.notify({ eventName: eventName, timestamp: event.timestamp.getTime(), slot: slot, category: category, path: event.path, }); Log_1.default.verbose(`[HttpServer] Storage change: ${eventName} ${category}${event.path} (slot ${slot})`); } async stop(reason) { // Stop all storage watchers this.stopAllStorageWatchers(); // Clean up MCP server resources (cached browser, preview server, etc.) if (this._mcpServer) { try { await this._mcpServer.cleanup(); } catch (e) { // Ignore cleanup errors during shutdown } this._mcpServer = undefined; this._mcpServerInitPromise = undefined; } // Notify all WebSocket clients that the server is shutting down BEFORE closing connections if (this._wsServer && this._wsClients.size > 0) { const shutdownNotification = { header: { version: 1, requestId: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`, messageType: "notification", messagePurpose: "event", }, body: { eventName: "serverShutdown", timestamp: Date.now(), reason: reason || "Server shutting down", graceful: true, }, }; const message = JSON.stringify(shutdownNotification); let sentCount = 0; for (const [socket] of this._wsClients) { try { if (socket.readyState === socket.OPEN) { socket.send(message); sentCount++; } } catch (e) { // Ignore send errors during shutdown } } Log_1.default.message(`Sent shutdown notification to ${sentCount} WebSocket clients.`); // Give the message a moment to be delivered before closing connections if (sentCount > 0) { await new Promise((resolve) => setTimeout(resolve, 100)); } } // Close WebSocket server and all connections if (this._wsServer) { for (const [socket] of this._wsClients) { try { socket.close(); } catch (e) { // Ignore close errors } } this._wsClients.clear(); this._wsServer.close(); Log_1.default.message("WebSocket notification server closed."); } // Reset listening state BEFORE closing servers this._isListeningMetaFlag = false; if (this._httpServer) { // Force close all connections immediately this._httpServer.closeAllConnections?.(); this._httpServer.close(() => { Log_1.default.message(`Minecraft HTTP server closed.`); }); // Unref the server so it doesn't keep the process alive this._httpServer.unref(); } if (this._httpsServer) { this._httpsServer.closeAllConnections?.(); this._httpsServer.close(() => { Log_1.default.message(`Minecraft https server closed.`); }); this._httpsServer.unref(); } } _salt; getPasswordHash() { if (!this._pwdHash) { // Generate salt once and store it this._salt = crypto.randomBytes(32); this._pwdHash = crypto.scryptSync(this._localEnvironment.tokenEncryptionKey, this._salt, 32); } return this._pwdHash; } getSalt() { if (!this._salt) { this.getPasswordHash(); // Initialize salt } return this._salt; } /** * Generates a generic fingerprint hash for token binding. * Uses partial IP and browser family to balance security with usability. * * Note: This is intentionally "soft" binding - we only use partial IP * to avoid breaking sessions for mobile users while still providing * some protection against token theft across different networks. */ generateFingerprint(userAgent, ipAddress) { // Extract only the IP network prefix (first 2 octets for IPv4, first 3 groups for IPv6) // This provides some binding while tolerating NAT/mobile IP changes within same ISP let networkPrefix = "unknown"; if (ipAddress) { // Normalize localhost addresses to a consistent value // This handles: 127.0.0.1, ::1, ::ffff:127.0.0.1, localhost if (ipAddress === "127.0.0.1" || ipAddress === "::1" || ipAddress === "::ffff:127.0.0.1" || ipAddress.startsWith("::ffff:127.")) { networkPrefix = "localhost"; } else if (ipAddress.includes(".")) { // IPv4: use first 2 octets (e.g., "192.168.x.x" -> "192.168") const parts = ipAddress.split("."); if (parts.length >= 2) { networkPrefix = `${parts[0]}.${parts[1]}`; } } else if (ipAddress.includes(":")) { // IPv6: use first 3 groups const parts = ipAddress.split(":"); if (parts.length >= 3) { networkPrefix = `${parts[0]}:${parts[1]}:${parts[2]}`; } } } // Extract browser family from user agent (not full string, for privacy) let browserFamily = "unknown"; if (userAgent) { if (userAgent.includes("Chrome") && !userAgent.includes("Edg")) { browserFamily = "chrome"; } else if (userAgent.includes("Firefox")) { browserFamily = "firefox"; } else if (userAgent.includes("Safari") && !userAgent.includes("Chrome")) { browserFamily = "safari"; } else if (userAgent.includes("Edg")) { browserFamily = "edge"; } } // Create a hash of the combined attributes const combined = `${networkPrefix}|${browserFamily}`; return crypto.createHash("sha256").update(combined).digest("hex").substring(0, 32); } /** * Validates that a token fingerprint matches current request. * Uses timing-safe comparison to prevent timing attacks. */ validateFingerprint(storedFingerprint, currentFingerprint) { if (storedFingerprint.length !== currentFingerprint.length) { return false; } try { return crypto.timingSafeEqual(Buffer.from(storedFingerprint, "utf8"), Buffer.from(currentFingerprint, "utf8")); } catch { return false; } } /** * Gets the expected session ID for a given permission level. * Used to validate that tokens were issued for the current server session. * This prevents replay attacks with tokens from previous server restarts. */ getExpectedSessionIdForPermission(permissionLevel) { switch (permissionLevel) { case IAuthenticationToken_1.ServerPermissionLevel.admin: return this._localEnvironment.adminSessionId; case IAuthenticationToken_1.ServerPermissionLevel.displayReadOnly: return this._localEnvironment.displayReadOnlySessionId; case IAuthenticationToken_1.ServerPermissionLevel.fullReadOnly: return this._localEnvironment.fullReadOnlySessionId; case IAuthenticationToken_1.ServerPermissionLevel.updateState: return this._localEnvironment.updateStateSessionId; default: return undefined; } } getAllowedCorsOrigins() { // Default allowed origins const defaultOrigins = ["http://localhost:6126", "http://127.0.0.1:6126"]; // Add configured origins if any if (this._localEnvironment.allowedCorsOrigins && this._localEnvironment.allowedCorsOrigins.length > 0) { return [...defaultOrigins, ...this._localEnvironment.allowedCorsOrigins]; } return defaultOrigins; } /** * Check if an origin is allowed for CORS. * Allows any localhost/127.0.0.1 port for development convenience. */ isOriginAllowed(origin) { if (!origin) { return false; } // Allow any localhost port (e.g., localhost:3000 for Vite dev server) // This matches http://localhost:<any-port> or http://127.0.0.1:<any-port> const localhostPattern = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/; if (localhostPattern.test(origin)) { return true; } const allowedOrigins = this.getAllowedCorsOrigins(); return allowedOrigins.includes(origin); } getCorsHeaders(req) { const origin = req.headers.origin; const allowedOrigin = this.isOriginAllowed(origin) ? origin : "null"; return { // CORS headers "Access-Control-Allow-Origin": allowedOrigin, "Access-Control-Allow-Methods": "OPTIONS, POST, GET, DELETE", "Access-Control-Max-Age": "86400", "Access-Control-Allow-Headers": "Content-Type, Authorization, mctpc, mcp-session-id", "Access-Control-Allow-Credentials": "true", Vary: "Origin", // Important for caching with multiple origins // Security headers "X-Content-Type-Options": "nosniff", // Prevent MIME type sniffing "X-Frame-Options": "DENY", // Prevent clickjacking "X-XSS-Protection": "1; mode=block", // Legacy XSS protection for older browsers "Referrer-Policy": "strict-origin-when-cross-origin", // Control referrer information // CSP for CLI-served web — no telemetry endpoints allowed (telemetry is only for mctools.dev) "Content-Security-Policy": "default-src 'self'; manifest-src 'self'; worker-src 'self' blob:; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'; connect-src 'self' https://raw.githubusercontent.com/ https://registry.npmjs.org/ https://mctools.dev wss:; font-src 'self' https://res-1.cdn.office.net https://res.cdn.office.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none';", }; } /** * Register temporary content to be served at a specific path. * Useful for serving project-specific content for headless rendering. * @param urlPath The URL path to serve (e.g., "/temp/geometry.json") * @param content The content to serve (string for JSON/text, Uint8Array for binary) * @param contentType The content type (e.g., "application/json", "image/png") */ registerTempContent(urlPath, content, contentType) { this._tempContent.set(urlPath, { content, contentType }); } /** * Unregister temporary content. * @param urlPath The URL path to remove */ unregisterTempContent(urlPath) { this._tempContent.delete(urlPath); } /** * Clear all temporary content. */ clearTempContent() { this._tempContent.clear(); } /** * Set the content path for serving local files via /api/content. * Used by the 'view' command to serve Minecraft content for browsing. * @param contentPath Absolute path to the local folder to serve */ setContentPath(contentPath) { this._contentPath = contentPath; this._contentStorage = new NodeStorage_1.default(contentPath, ""); } /** * Get the content path if set. */ getContentPath() { return this._contentPath; } /** * Set whether the server is running in "view" mode. * When true, enables the /api/shutdown endpoint for graceful shutdown. */ setViewMode(isViewMode) { this._isViewMode = isViewMode; } /** * Check if the server is running in view mode. */ isViewMode() { return this._isViewMode; } /** * Set whether the server is running in "edit" mode. * When true, enables write operations (PUT/DELETE) on /api/content endpoints. * Edit mode also enables view mode features like /api/shutdown. */ setEditMode(isEditMode) { this._isEditMode = isEditMode; // Edit mode implies view mode features (shutdown endpoint, etc.) if (isEditMode) { this._isViewMode = true; } } /** * Check if the server is running in edit mode. */ isEditMode() { return this._isEditMode; } /** * Set whe