@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
1,192 lines • 173 kB
JavaScript
"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