UNPKG

@ui5/fs

Version:

UI5 CLI - File System Abstraction

499 lines (454 loc) 15 kB
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 CLI 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 CLI 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 CLI extensions (i.e. custom tasks, custom middleware):</b> * In order to ensure compatibility across UI5 CLI 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;