@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
425 lines (424 loc) • 17.7 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* HttpStorage.ts
*
* ARCHITECTURE DOCUMENTATION
* ==========================
*
* HttpStorage is a client-side storage implementation that fetches content
* via HTTP and receives real-time updates via WebSocket notifications.
*
* REAL-TIME SYNCHRONIZATION:
* --------------------------
* 1. HttpStorage connects to /ws/notifications WebSocket endpoint
* 2. Subscribes to file/folder change events for specific slots
* 3. When notifications arrive, converts them to standard IStorage events
* 4. Consumers (MCWorld, etc.) subscribe to these events
*
* DATA FLOW:
* ----------
* NodeStorage (fs.watch) -> HttpServer (broadcast) -> WebSocket ->
* HttpStorage (this) -> notifyFileAdded/Removed/Updated -> MCWorld -> WorldView
*
* WEBSOCKET PROTOCOL:
* -------------------
* - Connect: ws://host:port/ws/notifications?token=<authToken>
* - Subscribe: { header: {..., messageType: "subscriptionRequest", messagePurpose: "subscribe" },
* body: { eventNames: ["fileChanged", "fileAdded", ...], slot: 0 } }
* - Receive: IServerNotification messages with file/folder change details
*
* RELATED FILES:
* --------------
* - IServerNotification.ts: WebSocket message format definitions
* - IStorageWatcher.ts: INotificationReceiver interface
* - HttpServer.ts: Server-side WebSocket broadcaster
* - NodeStorage.ts: Server-side file watcher source
*
* USAGE:
* ------
* const storage = HttpStorage.get("http://localhost:6126/api/worldContent/0/");
* storage.authToken = "encrypted-token";
* await storage.connectToNotifications();
* storage.onFileAdded.subscribe((sender, file) => console.log("File added:", file.name));
*/
const HttpFolder_1 = __importDefault(require("./HttpFolder"));
const StorageBase_1 = __importDefault(require("./StorageBase"));
const Log_1 = __importDefault(require("../core/Log"));
const IFile_1 = require("./IFile");
const ste_events_1 = require("ste-events");
class HttpStorage extends StorageBase_1.default {
rootFolder;
baseUrl;
/**
* Bearer token for Authorization header.
* When set, requests will include "Authorization: Bearer <token>" header.
* This is used for authenticated endpoints like /api/content.
*/
authToken;
/**
* When true (default), the storage is read-only and write operations will throw.
* Set to false to enable HTTP PUT/DELETE operations for editing content.
*/
readOnly = true;
/** WebSocket connection for receiving notifications */
_webSocket = null;
/** Currently subscribed event names */
_subscribedEvents = new Set();
/** Server slot this storage is associated with (for filtering notifications) */
_slot;
/** Whether we're currently connected to the notification server */
_isConnected = false;
/** Reconnection timer */
_reconnectTimer;
/** Whether auto-reconnect is enabled */
_autoReconnect = true;
/**
* Event fired when the server sends a shutdown notification.
* This indicates the entire MCT server is shutting down (not just a BDS instance).
* Subscribers should show appropriate UI feedback and disable auto-reconnect.
* Args: (reason: string, graceful: boolean)
*/
_onServerShutdown = new ste_events_1.EventDispatcher();
/**
* Subscribe to server shutdown notifications.
* This is fired when the MCT server is about to shut down.
*/
get onServerShutdown() {
return this._onServerShutdown.asEvent();
}
/**
* Static cache of HttpStorage instances by base URL.
* Used to avoid creating duplicate storage instances for the same URL.
*/
static _storageCache = new Map();
/**
* Get or create an HttpStorage instance for the given base URL.
* Reuses cached instances to avoid creating duplicates.
* @param baseUrl The base URL for the storage
* @returns A cached or new HttpStorage instance
*/
static get(baseUrl) {
// Normalize the URL to ensure consistent caching
let normalizedUrl = baseUrl;
if (!normalizedUrl.endsWith(StorageBase_1.default.slashFolderDelimiter)) {
normalizedUrl += StorageBase_1.default.slashFolderDelimiter;
}
let storage = HttpStorage._storageCache.get(normalizedUrl);
if (!storage) {
storage = new HttpStorage(baseUrl);
HttpStorage._storageCache.set(normalizedUrl, storage);
}
return storage;
}
/**
* Clear the storage cache. Useful for testing or when storage should be refreshed.
*/
static clearCache() {
HttpStorage._storageCache.clear();
}
constructor(newUrl) {
super();
this.baseUrl = newUrl;
if (!this.baseUrl.endsWith(StorageBase_1.default.slashFolderDelimiter)) {
this.baseUrl += StorageBase_1.default.slashFolderDelimiter;
}
this.rootFolder = new HttpFolder_1.default(this, null, "");
}
async getAvailable() {
this.available = true;
return this.available;
}
get isConnected() {
return this._isConnected;
}
/**
* Get the underlying WebSocket connection.
* Can be used to listen for raw notifications (e.g., debug stats).
*/
get webSocket() {
return this._webSocket;
}
/**
* Set the slot number for filtering notifications.
*/
set slot(value) {
this._slot = value;
}
get slot() {
return this._slot;
}
/**
* Connect to the WebSocket notification endpoint.
* The WebSocket URL is derived from the baseUrl.
*
* @param url Optional override URL for the WebSocket endpoint
* @param authToken Optional auth token (uses this.authToken if not provided)
*/
async connect(url, authToken) {
if (this._isConnected) {
return;
}
const token = authToken || this.authToken;
if (!token) {
Log_1.default.debug("Cannot connect to notifications: no auth token");
return;
}
// Derive WebSocket URL from baseUrl if not provided
let wsUrl = url;
if (!wsUrl) {
// Convert http(s)://host:port/api/... to ws(s)://host:port/ws/notifications
// Handle both absolute URLs and relative URLs (using window.location as base)
let urlObj;
try {
// First try as absolute URL
urlObj = new URL(this.baseUrl);
}
catch {
// If that fails, treat as relative URL and use current page location as base
// Use globalThis to safely access window in a cross-platform way
const globalWindow = globalThis;
if (globalWindow.location?.origin) {
urlObj = new URL(this.baseUrl, globalWindow.location.origin);
}
else {
Log_1.default.message("[HttpStorage] Cannot derive WebSocket URL: no window.location available");
return;
}
}
const wsProtocol = urlObj.protocol === "https:" ? "wss:" : "ws:";
wsUrl = `${wsProtocol}//${urlObj.host}/ws/notifications`;
}
// Add auth token as query parameter
wsUrl += `?token=${encodeURIComponent(token)}`;
try {
this._webSocket = new WebSocket(wsUrl);
// Set up event handlers
this._webSocket.onopen = () => {
this._isConnected = true;
Log_1.default.verbose(`[HttpStorage] WebSocket connected to ${wsUrl?.split("?")[0]}`);
// Re-subscribe to any previously subscribed events
if (this._subscribedEvents.size > 0) {
this._sendSubscription("subscribe", Array.from(this._subscribedEvents));
}
};
this._webSocket.onclose = () => {
this._isConnected = false;
Log_1.default.verbose("[HttpStorage] WebSocket connection closed");
// Auto-reconnect after a delay
if (this._autoReconnect && !this._reconnectTimer) {
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = undefined;
this.connect(url, authToken).catch((err) => {
Log_1.default.debug("[HttpStorage] WebSocket reconnection failed: " + err);
});
}, 5000);
}
};
this._webSocket.onerror = (error) => {
Log_1.default.message("[HttpStorage] WebSocket error: " + error);
};
this._webSocket.onmessage = (event) => {
Log_1.default.debug("[HttpStorage] Received WebSocket message: " + event.data.substring(0, 200));
this._handleNotification(event.data);
};
// Wait for connection to be established
await new Promise((resolve, reject) => {
if (!this._webSocket) {
reject(new Error("WebSocket not created"));
return;
}
const onOpen = () => {
this._webSocket?.removeEventListener("open", onOpen);
this._webSocket?.removeEventListener("error", onError);
resolve();
};
const onError = (e) => {
this._webSocket?.removeEventListener("open", onOpen);
this._webSocket?.removeEventListener("error", onError);
reject(new Error("WebSocket connection failed"));
};
if (this._webSocket.readyState === WebSocket.OPEN) {
resolve();
}
else {
this._webSocket.addEventListener("open", onOpen);
this._webSocket.addEventListener("error", onError);
}
});
}
catch (e) {
Log_1.default.debug("Failed to connect to notification server: " + e);
throw e;
}
}
/**
* Disconnect from the WebSocket notification server.
*/
disconnect() {
this._autoReconnect = false;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = undefined;
}
if (this._webSocket) {
this._webSocket.close();
this._webSocket = null;
}
this._isConnected = false;
}
/**
* Subscribe to specific event types.
*
* @param eventNames Array of event names to subscribe to
* @param slot Optional slot number to filter events
*/
async subscribe(eventNames, slot) {
for (const name of eventNames) {
this._subscribedEvents.add(name);
}
if (slot !== undefined) {
this._slot = slot;
}
if (this._isConnected) {
this._sendSubscription("subscribe", eventNames);
}
}
/**
* Unsubscribe from specific event types.
*/
async unsubscribe(eventNames) {
for (const name of eventNames) {
this._subscribedEvents.delete(name);
}
if (this._isConnected) {
this._sendSubscription("unsubscribe", eventNames);
}
}
/**
* Send a subscription request to the server.
*/
_sendSubscription(purpose, eventNames) {
if (!this._webSocket || this._webSocket.readyState !== WebSocket.OPEN) {
return;
}
const request = {
header: {
version: 1,
requestId: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
messageType: "subscriptionRequest",
messagePurpose: purpose,
},
body: {
eventNames: eventNames,
slot: this._slot,
},
};
Log_1.default.message(`[HttpStorage] Sending subscription request: ${JSON.stringify(request)}`);
this._webSocket.send(JSON.stringify(request));
}
/**
* Handle an incoming notification message.
*/
async _handleNotification(data) {
try {
const message = JSON.parse(data);
Log_1.default.debug(`[HttpStorage] _handleNotification: messageType=${message.header?.messageType}, eventName=${message.body?.eventName}, path=${message.body?.path}, category=${message.body?.category}`);
if (message.header.messageType !== "notification") {
Log_1.default.debug(`[HttpStorage] Skipping non-notification message: ${message.header.messageType}`);
return;
}
const { eventName, path, category } = message.body;
// Handle server shutdown notification specially
if (eventName === "serverShutdown") {
const reason = message.body.reason || "Server shutting down";
const graceful = message.body.graceful !== false; // Default to true if not specified
Log_1.default.verbose(`[HttpStorage] Server shutdown notification received: ${reason}`);
// Disable auto-reconnect since the server is going away intentionally
this._autoReconnect = false;
// Dispatch the shutdown event to subscribers
this._onServerShutdown.dispatch(this, { reason, graceful });
return;
}
// Skip if not a file/folder event
if (!path) {
Log_1.default.debug(`[HttpStorage] Skipping notification without path: ${eventName}`);
return;
}
// The server sends paths relative to the category storage (e.g., /db/000040.ldb for world storage)
// We need to prepend the category to get the full path relative to worldContentStorage
// e.g., /world/db/000040.ldb for the world category
// Note: getFileFromRelativePath expects paths to start with /
let fullPath = path;
if (category && !path.startsWith(category + "/") && !path.startsWith("/" + category + "/")) {
// Prepend category, handling leading slashes
const cleanPath = path.startsWith("/") ? path.substring(1) : path;
fullPath = `/${category}/${cleanPath}`;
}
// Ensure fullPath starts with /
if (!fullPath.startsWith("/")) {
fullPath = "/" + fullPath;
}
Log_1.default.message(`[HttpStorage] Processing ${eventName} for path: ${path} -> ${fullPath}`);
// Convert the notification to storage events
switch (eventName) {
case "fileAdded": {
// Ensure the file exists in our folder structure and notify
const file = await this.rootFolder.ensureFileFromRelativePath(fullPath);
Log_1.default.debug(`[HttpStorage] fileAdded: ensured file=${file?.fullPath}`);
if (file) {
this.notifyFileAdded(file);
}
break;
}
case "fileRemoved": {
this.notifyFileRemoved(fullPath);
Log_1.default.debug(`[HttpStorage] fileRemoved: notified for ${fullPath}`);
break;
}
case "fileChanged": {
// Reload the file content and notify
try {
const file = await this.rootFolder.getFileFromRelativePath(fullPath);
if (file) {
// Force reload content
await file.loadContent(true);
this.notifyFileContentsUpdated({
file: file,
updateType: IFile_1.FileUpdateType.externalChange,
sourceId: "websocket",
});
}
else {
Log_1.default.debug(`[HttpStorage] fileChanged: file not found for path ${fullPath}`);
}
}
catch (fileErr) {
Log_1.default.error(`[HttpStorage] fileChanged: error processing ${fullPath}: ${fileErr}`);
}
break;
}
case "folderChanged": {
// Reload the folder and notify
const folder = await this.rootFolder.getFolderFromRelativePath(fullPath);
Log_1.default.debug(`[HttpStorage] folderChanged: got folder=${folder?.fullPath}`);
if (folder) {
await folder.scanForChanges();
this.notifyFolderAdded(folder);
}
break;
}
}
Log_1.default.message(`[HttpStorage] Processed notification: ${eventName} ${fullPath}`);
}
catch (e) {
Log_1.default.message(`[HttpStorage] Error handling notification: ` + e);
}
}
}
exports.default = HttpStorage;