@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
359 lines (358 loc) • 13.8 kB
JavaScript
;
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
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 });
/**
* NodeStorage.ts
*
* ARCHITECTURE DOCUMENTATION
* ==========================
*
* NodeStorage is the local file system implementation of IStorage for Node.js environments.
* It provides real-time file system watching using Node.js fs.watch() API.
*
* FILE SYSTEM WATCHING:
* ---------------------
* - startWatching(): Begins recursive watching of the storage root folder
* - stopWatching(): Stops a specific watcher by ID
* - stopAllWatching(): Stops all watchers on this storage
*
* When file changes are detected:
* 1. fs.watch() callback fires with filename and event type
* 2. NodeStorage determines if it's a file or folder change
* 3. Appropriate notify*() method is called on StorageBase
* 4. Events propagate to listeners (HttpServer, etc.)
*
* DEBOUNCING:
* -----------
* File system events can fire multiple times for a single logical change.
* We debounce events by path, waiting 100ms after the last event before
* processing to coalesce rapid-fire events.
*
* PIPELINE INTEGRATION:
* ---------------------
* NodeStorage (this) -> HttpServer (traps events) -> WebSocket -> HttpStorage -> MCWorld -> WorldView
*
* RELATED FILES:
* --------------
* - IStorageWatcher.ts: Watcher interface definition
* - NodeFolder.ts: Folder implementation (also supports watching)
* - NodeFile.ts: File implementation
* - HttpServer.ts: Traps events and broadcasts via WebSocket
*/
const NodeFolder_1 = __importDefault(require("./NodeFolder"));
const StorageBase_1 = __importDefault(require("../storage/StorageBase"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const NodeFile_1 = __importDefault(require("./NodeFile"));
const ZipStorage_1 = __importDefault(require("../storage/ZipStorage"));
const Log_1 = __importDefault(require("../core/Log"));
const ste_events_1 = require("ste-events");
/** Default debounce delay for file system events in milliseconds */
const WATCHER_DEBOUNCE_MS = 100;
class NodeStorage extends StorageBase_1.default {
rootPath;
name;
rootFolder;
static platformFolderDelimiter = path.sep;
/** Active file system watchers, keyed by watcher ID */
_watchers = new Map();
/** Debounce timers for coalescing rapid file system events */
_debounceTimers = new Map();
/** Counter for generating unique watcher IDs */
_watcherIdCounter = 0;
/** Event dispatcher for storage change events */
#onStorageChange = new ste_events_1.EventDispatcher();
get folderDelimiter() {
return path.sep;
}
get isWatching() {
return this._watchers.size > 0;
}
get onStorageChange() {
return this.#onStorageChange.asEvent();
}
constructor(incomingPath, name) {
super();
if (NodeStorage.platformFolderDelimiter === "\\") {
incomingPath = incomingPath.replace(/\//gi, NodeStorage.platformFolderDelimiter);
incomingPath = incomingPath.replace(/\\\\/gi, "\\");
}
else if (NodeStorage.platformFolderDelimiter === "/") {
incomingPath = incomingPath.replace(/\\/gi, NodeStorage.platformFolderDelimiter);
incomingPath = incomingPath.replace(/\/\//gi, NodeStorage.platformFolderDelimiter);
}
this.rootPath = incomingPath;
this.name = name;
this.rootFolder = new NodeFolder_1.default(this, null, incomingPath, name);
}
joinPath(pathA, pathB) {
let fullPath = pathA;
if (!fullPath.endsWith(NodeStorage.platformFolderDelimiter)) {
fullPath += NodeStorage.platformFolderDelimiter;
}
fullPath += pathB;
return fullPath;
}
static async createFromPath(path) {
const lastDot = path.lastIndexOf(".");
let lastSlash = path.lastIndexOf("/");
let lastBackslash = path.lastIndexOf("\\");
if (lastBackslash > lastSlash) {
lastSlash = lastBackslash;
}
if (lastDot > lastSlash) {
const ns = new NodeStorage(path.substring(0, lastSlash), "");
return (await ns.rootFolder.ensureFileFromRelativePath(path.substring(lastSlash)));
}
else {
const ns = new NodeStorage(path, "");
return ns.rootFolder;
}
}
static async createFromPathIncludingZip(path) {
const content = await NodeStorage.createFromPath(path);
if (content instanceof NodeFile_1.default &&
(path.endsWith(".mcpack") ||
path.endsWith(".mcaddon") ||
path.endsWith(".mcworld") ||
path.endsWith(".zip") ||
path.endsWith(".mcproject"))) {
const zs = await ZipStorage_1.default.loadFromFile(content);
return zs?.rootFolder;
}
if (content instanceof NodeFolder_1.default) {
return content;
}
return undefined;
}
static getParentFolderPath(parentPath) {
const lastDelim = parentPath.lastIndexOf(this.platformFolderDelimiter);
if (lastDelim < 0) {
return parentPath;
}
return parentPath.substring(0, lastDelim);
}
static ensureEndsWithDelimiter(pth) {
if (!pth.endsWith(NodeStorage.platformFolderDelimiter)) {
pth = pth + NodeStorage.platformFolderDelimiter;
}
return pth;
}
static ensureStartsWithDelimiter(pth) {
if (!pth.startsWith(NodeStorage.platformFolderDelimiter)) {
pth = NodeStorage.platformFolderDelimiter + pth;
}
return pth;
}
async getAvailable() {
this.available = true;
return this.available;
}
/**
* Start watching the storage for file system changes.
* Uses Node.js fs.watch() with recursive option where supported.
*
* @returns A unique watcher ID that can be used to stop this watcher
*/
startWatching() {
const watcherId = `watcher-${++this._watcherIdCounter}`;
const watchers = [];
try {
// Create a recursive watcher on the root path
const watcher = fs.watch(this.rootPath, { recursive: true, persistent: false }, (eventType, filename) => {
if (filename) {
this._handleWatchEvent(watcherId, eventType, filename);
}
});
watcher.on("error", (err) => {
Log_1.default.debug(`Watcher error for ${this.rootPath}: ${err.message}`);
});
watchers.push(watcher);
this._watchers.set(watcherId, watchers);
Log_1.default.verbose(`Started watching ${this.rootPath} with ID ${watcherId}`);
}
catch (err) {
Log_1.default.debug(`Failed to start watcher for ${this.rootPath}: ${err.message}`);
}
return watcherId;
}
/**
* Stop a specific watcher by ID.
*/
stopWatching(watcherId) {
const watchers = this._watchers.get(watcherId);
if (watchers) {
for (const watcher of watchers) {
try {
watcher.close();
}
catch (e) {
// Ignore close errors
}
}
this._watchers.delete(watcherId);
Log_1.default.verbose(`Stopped watcher ${watcherId} for ${this.rootPath}`);
}
}
/**
* Stop all watchers on this storage.
*/
stopAllWatching() {
for (const watchers of this._watchers.values()) {
for (const watcher of watchers) {
try {
watcher.close();
}
catch (e) {
// Ignore close errors
}
}
}
this._watchers.clear();
// Clear all debounce timers
for (const timer of this._debounceTimers.values()) {
clearTimeout(timer);
}
this._debounceTimers.clear();
Log_1.default.verbose(`Stopped all watchers for ${this.rootPath}`);
}
/**
* Handle a file system watch event with debouncing.
*/
_handleWatchEvent(watcherId, eventType, filename) {
// Create a debounce key based on the filename
const debounceKey = `${watcherId}:${filename}`;
// Clear any existing debounce timer for this path
const existingTimer = this._debounceTimers.get(debounceKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set a new debounce timer
const timer = setTimeout(() => {
this._debounceTimers.delete(debounceKey);
this._processWatchEvent(eventType, filename);
}, WATCHER_DEBOUNCE_MS);
this._debounceTimers.set(debounceKey, timer);
}
/**
* Process a debounced file system event.
*/
async _processWatchEvent(eventType, filename) {
try {
const fullPath = path.join(this.rootPath, filename);
const relativePath = "/" + filename.replace(/\\/g, "/");
// Check if the path exists
const exists = fs.existsSync(fullPath);
// Determine if it's a file or folder
let isFile = true;
let isDirectory = false;
if (exists) {
try {
const stat = fs.statSync(fullPath);
isFile = stat.isFile();
isDirectory = stat.isDirectory();
}
catch (e) {
// If we can't stat it, assume it was removed
isFile = true;
}
}
// Determine change type
let changeType;
if (!exists) {
changeType = "removed";
}
else if (eventType === "rename") {
// "rename" can mean added or removed - we check existence above
changeType = "added";
}
else {
changeType = "modified";
}
// Create and dispatch the storage change event
const changeEvent = {
changeType,
path: relativePath,
isFile,
timestamp: new Date(),
source: "fswatch",
};
this.#onStorageChange.dispatch(this, changeEvent);
// Also dispatch the appropriate specific event
if (isFile) {
if (changeType === "removed") {
this.notifyFileRemoved(relativePath);
}
else if (changeType === "added") {
// Get or create the file and notify
const file = await this.rootFolder.getFileFromRelativePath(relativePath);
if (file) {
this.notifyFileAdded(file);
}
}
else {
// Modified - reload and notify
const file = await this.rootFolder.getFileFromRelativePath(relativePath);
if (file) {
await file.scanForChanges();
}
}
}
else if (isDirectory) {
if (changeType === "removed") {
// Folder removed - find and notify
this.notifyFolderRemoved({ storageRelativePath: relativePath, name: path.basename(filename) });
}
else if (changeType === "added") {
// Folder added
const folder = await this.rootFolder.getFolderFromRelativePath(relativePath);
if (folder) {
this.notifyFolderAdded(folder);
}
}
}
Log_1.default.verbose(`Storage change: ${changeType} ${isFile ? "file" : "folder"} ${relativePath}`);
}
catch (err) {
Log_1.default.debug(`Error processing watch event for ${filename}: ${err.message}`);
}
}
}
exports.default = NodeStorage;