fs/lib/Resource.js

import stream from "node:stream";
import clone from "clone";
import posixPath from "node:path/posix";

const fnTrue = () => true;
const fnFalse = () => false;
const ALLOWED_SOURCE_METADATA_KEYS = ["adapter", "fsPath", "contentModified"];

/**
 * Resource. UI5 Tooling specific representation of a file's content and metadata
 *
 * @public
 * @class
 * @alias @ui5/fs/Resource
 */
class Resource {
	#project;
	#buffer;
	#buffering;
	#collections;
	#contentDrained;
	#createStream;
	#name;
	#path;
	#sourceMetadata;
	#statInfo;
	#stream;
	#streamDrained;
	#isModified;

	/**
	* Function for dynamic creation of content streams
	*
	* @public
	* @callback @ui5/fs/Resource~createStream
	* @returns {stream.Readable} A readable stream of a resources content
	*/

	/**
	 *
	 * @public
	 * @param {object} parameters Parameters
	 * @param {string} parameters.path Absolute virtual path of the resource
	 * @param {fs.Stats|object} [parameters.statInfo] File information. Instance of
	 *					[fs.Stats]{@link https://nodejs.org/api/fs.html#fs_class_fs_stats} or similar object
	 * @param {Buffer} [parameters.buffer] Content of this resources as a Buffer instance
	 *					(cannot be used in conjunction with parameters string, stream or createStream)
	 * @param {string} [parameters.string] Content of this resources as a string
	 *					(cannot be used in conjunction with parameters buffer, stream or createStream)
	 * @param {Stream} [parameters.stream] Readable stream of the content of this resource
	 *					(cannot be used in conjunction with parameters buffer, string or createStream)
	 * @param {@ui5/fs/Resource~createStream} [parameters.createStream] Function callback that returns a readable
	 *					stream of the content of this resource (cannot be used in conjunction with parameters buffer,
	 *					string or stream).
	 *					In some cases this is the most memory-efficient way to supply resource content
	 * @param {@ui5/project/specifications/Project} [parameters.project] Project this resource is associated with
	 * @param {object} [parameters.sourceMetadata] Source metadata for UI5 Tooling internal use.
	 * 	Some information may be set by an adapter to store information for later retrieval. Also keeps track of whether
	 *  a resource content has been modified since it has been read from a source
	 */
	constructor({path, statInfo, buffer, string, createStream, stream, project, sourceMetadata}) {
		if (!path) {
			throw new Error("Unable to create Resource: Missing parameter 'path'");
		}
		if (buffer && createStream || buffer && string || string && createStream || buffer && stream ||
				string && stream || createStream && stream) {
			throw new Error("Unable to create Resource: Please set only one content parameter. " +
				"'buffer', 'string', 'stream' or 'createStream'");
		}

		if (sourceMetadata) {
			if (typeof sourceMetadata !== "object") {
				throw new Error(`Parameter 'sourceMetadata' must be of type "object"`);
			}

			/* eslint-disable-next-line guard-for-in */
			for (const metadataKey in sourceMetadata) { // Also check prototype
				if (!ALLOWED_SOURCE_METADATA_KEYS.includes(metadataKey)) {
					throw new Error(`Parameter 'sourceMetadata' contains an illegal attribute: ${metadataKey}`);
				}
				if (!["string", "boolean"].includes(typeof sourceMetadata[metadataKey])) {
					throw new Error(
						`Attribute '${metadataKey}' of parameter 'sourceMetadata' ` +
						`must be of type "string" or "boolean"`);
				}
			}
		}

		this.setPath(path);

		this.#sourceMetadata = sourceMetadata || {};

		// This flag indicates whether a resource has changed from its original source.
		// resource.isModified() is not sufficient, since it only reflects the modification state of the
		// current instance.
		// Since the sourceMetadata object is inherited to clones, it is the only correct indicator
		this.#sourceMetadata.contentModified ??= false;

		this.#isModified = false;

		this.#project = project;

		this.#statInfo = statInfo || { // TODO
			isFile: fnTrue,
			isDirectory: fnFalse,
			isBlockDevice: fnFalse,
			isCharacterDevice: fnFalse,
			isSymbolicLink: fnFalse,
			isFIFO: fnFalse,
			isSocket: fnFalse,
			atimeMs: new Date().getTime(),
			mtimeMs: new Date().getTime(),
			ctimeMs: new Date().getTime(),
			birthtimeMs: new Date().getTime(),
			atime: new Date(),
			mtime: new Date(),
			ctime: new Date(),
			birthtime: new Date()
		};

		if (createStream) {
			this.#createStream = createStream;
		} else if (stream) {
			this.#stream = stream;
		} else if (buffer) {
			// Use private setter, not to accidentally set any modified flags
			this.#setBuffer(buffer);
		} else if (typeof string === "string" || string instanceof String) {
			// Use private setter, not to accidentally set any modified flags
			this.#setBuffer(Buffer.from(string, "utf8"));
		}

		// Tracing:
		this.#collections = [];
	}

	/**
	 * Gets a buffer with the resource content.
	 *
	 * @public
	 * @returns {Promise<Buffer>} Promise resolving with a buffer of the resource content.
	 */
	async getBuffer() {
		if (this.#contentDrained) {
			throw new Error(`Content of Resource ${this.#path} has been drained. ` +
				"This might be caused by requesting resource content after a content stream has been " +
				"requested and no new content (e.g. a new stream) has been set.");
		}
		if (this.#buffer) {
			return this.#buffer;
		} else if (this.#createStream || this.#stream) {
			return this.#getBufferFromStream();
		} else {
			throw new Error(`Resource ${this.#path} has no content`);
		}
	}

	/**
	 * Sets a Buffer as content.
	 *
	 * @public
	 * @param {Buffer} buffer Buffer instance
	 */
	setBuffer(buffer) {
		this.#sourceMetadata.contentModified = true;
		this.#isModified = true;
		this.#setBuffer(buffer);
	}

	#setBuffer(buffer) {
		this.#createStream = null;
		// if (this.#stream) { // TODO this may cause strange issues
		// 	this.#stream.destroy();
		// }
		this.#stream = null;
		this.#buffer = buffer;
		this.#contentDrained = false;
		this.#streamDrained = false;
	}

	/**
	 * Gets a string with the resource content.
	 *
	 * @public
	 * @returns {Promise<string>} Promise resolving with the resource content.
	 */
	getString() {
		if (this.#contentDrained) {
			return Promise.reject(new Error(`Content of Resource ${this.#path} has been drained. ` +
				"This might be caused by requesting resource content after a content stream has been " +
				"requested and no new content (e.g. a new stream) has been set."));
		}
		return this.getBuffer().then((buffer) => buffer.toString());
	}

	/**
	 * Sets a String as content
	 *
	 * @public
	 * @param {string} string Resource content
	 */
	setString(string) {
		this.setBuffer(Buffer.from(string, "utf8"));
	}

	/**
	 * Gets a readable stream for the resource content.
	 *
	 * Repetitive calls of this function are only possible if new content has been set in the meantime (through
	 * [setStream]{@link @ui5/fs/Resource#setStream}, [setBuffer]{@link @ui5/fs/Resource#setBuffer}
	 * or [setString]{@link @ui5/fs/Resource#setString}). This
	 * is to prevent consumers from accessing drained streams.
	 *
	 * @public
	 * @returns {stream.Readable} Readable stream for the resource content.
	 */
	getStream() {
		if (this.#contentDrained) {
			throw new Error(`Content of Resource ${this.#path} has been drained. ` +
				"This might be caused by requesting resource content after a content stream has been " +
				"requested and no new content (e.g. a new stream) has been set.");
		}
		let contentStream;
		if (this.#buffer) {
			const bufferStream = new stream.PassThrough();
			bufferStream.end(this.#buffer);
			contentStream = bufferStream;
		} else if (this.#createStream || this.#stream) {
			contentStream = this.#getStream();
		}
		if (!contentStream) {
			throw new Error(`Resource ${this.#path} has no content`);
		}
		// If a stream instance is being returned, it will typically get drained be the consumer.
		// In that case, further content access will result in a "Content stream has been drained" error.
		// However, depending on the execution environment, a resources content stream might have been
		//	transformed into a buffer. In that case further content access is possible as a buffer can't be
		//	drained.
		// To prevent unexpected "Content stream has been drained" errors caused by changing environments, we flag
		//	the resource content as "drained" every time a stream is requested. Even if actually a buffer or
		//	createStream callback is being used.
		this.#contentDrained = true;
		return contentStream;
	}

	/**
	 * Sets a readable stream as content.
	 *
	 * @public
	 * @param {stream.Readable|@ui5/fs/Resource~createStream} stream Readable stream of the resource content or
	 														callback for dynamic creation of a readable stream
	 */
	setStream(stream) {
		this.#isModified = true;
		this.#sourceMetadata.contentModified = true;

		this.#buffer = null;
		// if (this.#stream) { // TODO this may cause strange issues
		// 	this.#stream.destroy();
		// }
		if (typeof stream === "function") {
			this.#createStream = stream;
			this.#stream = null;
		} else {
			this.#stream = stream;
			this.#createStream = null;
		}
		this.#contentDrained = false;
		this.#streamDrained = false;
	}

	/**
	 * Gets the virtual resources path
	 *
	 * @public
	 * @returns {string} Virtual path of the resource
	 */
	getPath() {
		return this.#path;
	}

	/**
	 * Sets the virtual resources path
	 *
	 * @public
	 * @param {string} path Absolute virtual path of the resource
	 */
	setPath(path) {
		path = posixPath.normalize(path);
		if (!posixPath.isAbsolute(path)) {
			throw new Error(`Unable to set resource path: Path must be absolute: ${path}`);
		}
		this.#path = path;
		this.#name = posixPath.basename(path);
	}

	/**
	 * Gets the resource name
	 *
	 * @public
	 * @returns {string} Name of the resource
	 */
	getName() {
		return this.#name;
	}

	/**
	 * Gets the resources stat info.
	 * Note that a resources stat information is not updated when the resource is being modified.
	 * Also, depending on the used adapter, some fields might be missing which would be present for a
	 * [fs.Stats]{@link https://nodejs.org/api/fs.html#fs_class_fs_stats} instance.
	 *
	 * @public
	 * @returns {fs.Stats|object} Instance of [fs.Stats]{@link https://nodejs.org/api/fs.html#fs_class_fs_stats}
	 *								or similar object
	 */
	getStatInfo() {
		return this.#statInfo;
	}

	/**
	 * Size in bytes allocated by the underlying buffer.
	 *
	 * @see {TypedArray#byteLength}
	 * @returns {Promise<number>} size in bytes, <code>0</code> if there is no content yet
	 */
	async getSize() {
		// if resource does not have any content it should have 0 bytes
		if (!this.#buffer && !this.#createStream && !this.#stream) {
			return 0;
		}
		const buffer = await this.getBuffer();
		return buffer.byteLength;
	}

	/**
	 * Adds a resource collection name that was involved in locating this resource.
	 *
	 * @param {string} name Resource collection name
	 */
	pushCollection(name) {
		this.#collections.push(name);
	}

	/**
	 * Returns a clone of the resource. The clones content is independent from that of the original resource
	 *
	 * @public
	 * @returns {Promise<@ui5/fs/Resource>} Promise resolving with the clone
	 */
	async clone() {
		const options = await this.#getCloneOptions();
		return new Resource(options);
	}

	async #getCloneOptions() {
		const options = {
			path: this.#path,
			statInfo: clone(this.#statInfo),
			sourceMetadata: clone(this.#sourceMetadata)
		};

		if (this.#stream) {
			options.buffer = await this.#getBufferFromStream();
		} else if (this.#createStream) {
			options.createStream = this.#createStream;
		} else if (this.#buffer) {
			options.buffer = this.#buffer;
		}

		return options;
	}

	/**
	 * Retrieve the project assigned to the resource
	 * <br/>
	 * <b>Note for UI5 Tooling extensions (i.e. custom tasks, custom middleware):</b>
	 * In order to ensure compatibility across UI5 Tooling versions, consider using the
	 * <code>getProject(resource)</code> method provided by
	 * [TaskUtil]{@link module:@ui5/project/build/helpers/TaskUtil} and
	 * [MiddlewareUtil]{@link module:@ui5/server.middleware.MiddlewareUtil}, which will
	 * return a Specification Version-compatible Project interface.
	 *
	 * @public
	 * @returns {@ui5/project/specifications/Project} Project this resource is associated with
	 */
	getProject() {
		return this.#project;
	}

	/**
	 * Assign a project to the resource
	 *
	 * @public
	 * @param {@ui5/project/specifications/Project} project Project this resource is associated with
	 */
	setProject(project) {
		if (this.#project) {
			throw new Error(`Unable to assign project ${project.getName()} to resource ${this.#path}: ` +
				`Resource is already associated to project ${this.#project}`);
		}
		this.#project = project;
	}

	/**
	 * Check whether a project has been assigned to the resource
	 *
	 * @public
	 * @returns {boolean} True if the resource is associated with a project
	 */
	hasProject() {
		return !!this.#project;
	}

	/**
	 * Check whether the content of this resource has been changed during its life cycle
	 *
	 * @public
	 * @returns {boolean} True if the resource's content has been changed
	 */
	isModified() {
		return this.#isModified;
	}

	/**
	 * Tracing: Get tree for printing out trace
	 *
	 * @returns {object} Trace tree
	 */
	getPathTree() {
		const tree = Object.create(null);

		let pointer = tree[this.#path] = Object.create(null);

		for (let i = this.#collections.length - 1; i >= 0; i--) {
			pointer = pointer[this.#collections[i]] = Object.create(null);
		}

		return tree;
	}

	/**
	 * Returns source metadata which may contain information specific to the adapter that created the resource
	 * Typically set by an adapter to store information for later retrieval.
	 *
	 * @returns {object}
	 */
	getSourceMetadata() {
		return this.#sourceMetadata;
	}

	/**
	 * Returns the content as stream.
	 *
	 * @private
	 * @returns {stream.Readable} Readable stream
	 */
	#getStream() {
		if (this.#streamDrained) {
			throw new Error(`Content stream of Resource ${this.#path} is flagged as drained.`);
		}
		if (this.#createStream) {
			return this.#createStream();
		}
		this.#streamDrained = true;
		return this.#stream;
	}

	/**
	 * Converts the buffer into a stream.
	 *
	 * @private
	 * @returns {Promise<Buffer>} Promise resolving with buffer.
	 */
	#getBufferFromStream() {
		if (this.#buffering) { // Prevent simultaneous buffering, causing unexpected access to drained stream
			return this.#buffering;
		}
		return this.#buffering = new Promise((resolve, reject) => {
			const contentStream = this.#getStream();
			const buffers = [];
			contentStream.on("data", (data) => {
				buffers.push(data);
			});
			contentStream.on("error", (err) => {
				reject(err);
			});
			contentStream.on("end", () => {
				const buffer = Buffer.concat(buffers);
				this.#setBuffer(buffer);
				this.#buffering = null;
				resolve(buffer);
			});
		});
	}
}

export default Resource;