UNPKG

webgme-engine

Version:

WebGME server and Client API without a GUI

738 lines (639 loc) 30.7 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>JSDoc: Source: common/blob/BlobClient.js</title> <script src="scripts/prettify/prettify.js"> </script> <script src="scripts/prettify/lang-css.js"> </script> <!--[if lt IE 9]> <script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script> <![endif]--> <link type="text/css" rel="stylesheet" href="styles/prettify-tomorrow.css"> <link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css"> </head> <body> <div id="main"> <h1 class="page-title">Source: common/blob/BlobClient.js</h1> <section> <article> <pre class="prettyprint source linenums"><code>/*globals define, WebGMEGlobal*/ /*eslint-env node, browser*/ /** * Client module for accessing the blob. * * @author lattmann / https://github.com/lattmann * @author ksmyth / https://github.com/ksmyth */ define([ 'blob/Artifact', 'blob/BlobMetadata', 'superagent', 'q', 'common/util/uint' ], function (Artifact, BlobMetadata, superagent, Q, UINT) { 'use strict'; /** * Client to interact with the blob-storage. &lt;br> * * @param {object} parameters * @param {object} parameters.logger * @constructor * @alias BlobClient */ var BlobClient = function (parameters) { var self = this; // Store these to be able to create a new instance from an instance. this.parameters = parameters; this.artifacts = []; if (parameters &amp;&amp; parameters.logger) { this.logger = parameters.logger; } else { /*eslint-disable no-console*/ var doLog = function () { console.log.apply(console, arguments); }; this.logger = { debug: doLog, log: doLog, info: doLog, warn: doLog, error: doLog }; console.warn('Since v1.3.0 BlobClient requires a logger, falling back on console.log.'); /*eslint-enable no-console*/ } if (parameters &amp;&amp; parameters.uploadProgressHandler) { this.uploadProgressHandler = parameters.uploadProgressHandler; } else { this.uploadProgressHandler = function (fName, e) { self.logger.debug('File upload of', fName, e.percent, '%'); }; } this.logger.debug('ctor', {metadata: parameters}); if (parameters) { this.server = parameters.server || this.server; this.serverPort = parameters.serverPort || this.serverPort; this.httpsecure = (parameters.httpsecure !== undefined) ? parameters.httpsecure : this.httpsecure; this.apiToken = parameters.apiToken; this.webgmeToken = parameters.webgmeToken; this.keepaliveAgentOptions = parameters.keepaliveAgentOptions || {/* use defaults */}; } else { this.keepaliveAgentOptions = {/* use defaults */}; } this.origin = ''; if (this.httpsecure !== undefined &amp;&amp; this.server &amp;&amp; this.serverPort) { this.origin = (this.httpsecure ? 'https://' : 'http://') + this.server + ':' + this.serverPort; } if (parameters &amp;&amp; typeof parameters.relativeUrl === 'string') { this.relativeUrl = parameters.relativeUrl; } else if (typeof WebGMEGlobal !== 'undefined' &amp;&amp; WebGMEGlobal.gmeConfig &amp;&amp; typeof WebGMEGlobal.gmeConfig.client.mountedPath === 'string') { this.relativeUrl = WebGMEGlobal.gmeConfig.client.mountedPath + '/rest/blob/'; } else { this.relativeUrl = '/rest/blob/'; } this.blobUrl = this.origin + this.relativeUrl; this.isNodeOrNodeWebKit = typeof process !== 'undefined'; if (this.isNodeOrNodeWebKit) { // node or node-webkit this.logger.debug('Running under node or node-web-kit'); if (this.httpsecure) { this.Agent = require('agentkeepalive').HttpsAgent; } else { this.Agent = require('agentkeepalive'); } if (Object.hasOwn(this.keepaliveAgentOptions, 'ca') === false) { this.keepaliveAgentOptions.ca = require('https').globalAgent.options.ca; } this.keepaliveAgent = new this.Agent(this.keepaliveAgentOptions); } this.logger.debug('origin', this.origin); this.logger.debug('blobUrl', this.blobUrl); }; /** * Creates and returns a new instance of a BlobClient with the same settings as the current one. * This can be used to avoid issues with the artifacts being book-kept at the instance. * @returns {BlobClient} A new instance of a BlobClient */ BlobClient.prototype.getNewInstance = function () { return new BlobClient(this.parameters); }; BlobClient.prototype.getMetadataURL = function (hash) { return this.origin + this.getRelativeMetadataURL(hash); }; BlobClient.prototype.getRelativeMetadataURL = function (hash) { var metadataBase = this.relativeUrl + 'metadata'; if (hash) { return metadataBase + '/' + hash; } else { return metadataBase; } }; BlobClient.prototype._getURL = function (base, hash, subpath) { var subpathURL = ''; if (subpath) { subpathURL = subpath; } return this.relativeUrl + base + '/' + hash + '/' + encodeURIComponent(subpathURL); }; BlobClient.prototype.getViewURL = function (hash, subpath) { return this.origin + this.getRelativeViewURL(hash, subpath); }; BlobClient.prototype.getRelativeViewURL = function (hash, subpath) { return this._getURL('view', hash, subpath); }; /** * Returns the get-url for downloading a blob. * @param {string} metadataHash * @param {string} [subpath] - optional file-like path to sub-object if complex blob * @return {string} get-url for blob */ BlobClient.prototype.getDownloadURL = function (metadataHash, subpath) { return this.origin + this.getRelativeDownloadURL(metadataHash, subpath); }; BlobClient.prototype.getRelativeDownloadURL = function (hash, subpath) { return this._getURL('download', hash, subpath); }; BlobClient.prototype.getCreateURL = function (filename, isMetadata) { return this.origin + this.getRelativeCreateURL(filename, isMetadata); }; BlobClient.prototype.getRelativeCreateURL = function (filename, isMetadata) { if (isMetadata) { return this.relativeUrl + 'createMetadata/'; } else { return this.relativeUrl + 'createFile/' + encodeURIComponent(filename); } }; /** * Adds a file to the blob storage. * @param {string} name - file name. * @param {string|Buffer|ArrayBuffer|stream.Readable} data - file content. * !ReadStream currently only available from a nodejs setting * @param {function} [callback] - if provided no promise will be returned. * * @return {external:Promise} On success the promise will be resolved with {string} &lt;b>metadataHash&lt;/b>.&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.putFile = function (name, data, callback) { var deferred = Q.defer(), self = this, contentLength, req, stream = null; this.logger.debug('putFile', name); function toArrayBuffer(buffer) { var ab = new ArrayBuffer(buffer.length), view = new Uint8Array(ab); for (var i = 0; i &lt; buffer.length; ++i) { view[i] = buffer[i]; } return ab; } if (typeof window === 'undefined') { stream = require('stream'); } // On node-webkit, we use XMLHttpRequest, but xhr.send thinks a Buffer is a string and encodes it in utf-8 - // send an ArrayBuffer instead. if (typeof window !== 'undefined' &amp;&amp; typeof Buffer !== 'undefined' &amp;&amp; data instanceof Buffer) { data = toArrayBuffer(data); // FIXME will this have performance problems } // on node, empty Buffers will cause a crash in superagent if (typeof window === 'undefined' &amp;&amp; typeof Buffer !== 'undefined' &amp;&amp; data instanceof Buffer) { if (data.length === 0) { data = ''; } } contentLength = Object.hasOwn(data, 'length') ? data.length : data.byteLength; req = superagent.post(this.getCreateURL(name)); if (typeof window === 'undefined') { req.agent(this.keepaliveAgent); } this._setAuthHeaders(req); if (typeof data !== 'string' &amp;&amp; !(data instanceof String) &amp;&amp; typeof window === 'undefined' &amp;&amp; !(data instanceof stream.Readable)) { req.set('Content-Length', contentLength); } req.set('Content-Type', 'application/octet-stream'); if (typeof window === 'undefined' &amp;&amp; data instanceof stream.Readable) { const DEFAULT_ERROR = new Error('Failed to send stream data completely'); const errorHandler = err => deferred.reject(err || DEFAULT_ERROR); data.on('error', errorHandler); req.on('error', errorHandler); req.on('response', function (res) { var response = res.body; // Get the first one var hash = Object.keys(response)[0]; self.logger.debug('putFile - result', hash); deferred.resolve(hash); }); data.pipe(req); } else { req.send(data) .on('progress', function (event) { self.uploadProgressHandler(name, event); }) .end(function (err, res) { if (err || res.status > 399) { deferred.reject(err || new Error(res.status)); return; } var response = res.body; // Get the first one var hash = Object.keys(response)[0]; self.logger.debug('putFile - result', hash); deferred.resolve(hash); }); } return deferred.promise.nodeify(callback); }; BlobClient.prototype._setAuthHeaders = function (req) { if (this.apiToken) { req.set('x-api-token', this.apiToken); } else if (this.webgmeToken) { req.set('Authorization', 'Bearer ' + this.webgmeToken); } }; BlobClient.prototype.putMetadata = function (metadataDescriptor, callback) { var metadata = new BlobMetadata(metadataDescriptor), deferred = Q.defer(), self = this, blob, contentLength, req; // FIXME: in production mode do not indent the json file. this.logger.debug('putMetadata', {metadata: metadataDescriptor}); if (typeof Blob !== 'undefined' &amp;&amp; typeof window !== 'undefined') { // This does not work using the "new" Blob class in nodejs - so make sure (for now at least) that // we running under a brower even though Blob is defined. // https://nodejs.org/api/buffer.html#class-blob blob = new Blob([JSON.stringify(metadata.serialize(), null, 4)], {type: 'text/plain'}); contentLength = blob.size; } else { blob = Buffer.from(JSON.stringify(metadata.serialize(), null, 4), 'utf8'); contentLength = blob.length; } req = superagent.post(this.getCreateURL(metadataDescriptor.name, true)); this._setAuthHeaders(req); if (typeof window === 'undefined') { req.agent(this.keepaliveAgent); req.set('Content-Length', contentLength); } req.set('Content-Type', 'application/octet-stream') .send(blob) .end(function (err, res) { if (err || res.status > 399) { deferred.reject(err || new Error(res.status)); return; } // Uploaded. var response = JSON.parse(res.text); // Get the first one var hash = Object.keys(response)[0]; self.logger.debug('putMetadata - result', hash); deferred.resolve(hash); }); return deferred.promise.nodeify(callback); }; /** * Adds multiple files to the blob storage. * @param {object.&lt;string, string|Buffer|ArrayBuffer>} o - Keys are file names and values the content. * @param {function} [callback] - if provided no promise will be returned. * * @return {external:Promise} On success the promise will be resolved with {object} * &lt;b>fileNamesToMetadataHashes&lt;/b>.&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.putFiles = function (o, callback) { var self = this, deferred = Q.defer(), error, filenames = Object.keys(o), remaining = filenames.length, hashes = {}, putFile; if (remaining === 0) { deferred.resolve(hashes); } putFile = function (filename, data) { self.putFile(filename, data, function (err, hash) { remaining -= 1; hashes[filename] = hash; if (err) { error = err; self.logger.error('putFile failed with error', {metadata: err}); } if (remaining === 0) { if (error) { deferred.reject(error); } else { deferred.resolve(hashes); } } }); }; for (var j = 0; j &lt; filenames.length; j += 1) { putFile(filenames[j], o[filenames[j]]); } return deferred.promise.nodeify(callback); }; BlobClient.prototype.getSubObject = function (hash, subpath, callback) { return this.getObject(hash, callback, subpath); }; /** * Retrieves object from blob storage as a Buffer under node and as an ArrayBuffer in the client. * N.B. if the retrieved file is a json-file and running in a browser, the content will be decoded and * the string parsed as a JSON. * @param {string} metadataHash - hash of metadata for object. * @param {function} [callback] - if provided no promise will be returned. * @param {string} [subpath] - optional file-like path to sub-object if complex blob * * @return {external:Promise} On success the promise will be resolved with {Buffer|ArrayBuffer|object} * &lt;b>content&lt;/b>.&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.getObject = function (metadataHash, callback, subpath) { var deferred = Q.defer(), self = this; this.logger.debug('getObject', metadataHash, subpath); superagent.parse['application/zip'] = function (obj, parseCallback) { if (parseCallback) { // Running on node; this should be unreachable due to req.pipe() below } else { return obj; } }; //superagent.parse['application/json'] = superagent.parse['application/zip']; var req = superagent.get(this.getViewURL(metadataHash, subpath)); this._setAuthHeaders(req); if (typeof window === 'undefined') { // running on node req.agent(this.keepaliveAgent); var Writable = require('stream').Writable; var BuffersWritable = function (options) { Writable.call(this, options); var self = this; self.buffers = []; }; require('util').inherits(BuffersWritable, Writable); BuffersWritable.prototype._write = function (chunk, encoding, cb) { this.buffers.push(chunk); cb(); }; var buffers = new BuffersWritable(); buffers.on('finish', function () { if (req.req.res.statusCode > 399) { deferred.reject(new Error(req.req.res.statusCode)); } else { deferred.resolve(Buffer.concat(buffers.buffers)); } }); buffers.on('error', function (err) { deferred.reject(err); }); req.pipe(buffers); } else { req.removeAllListeners('end'); req.on('request', function () { if (typeof this.xhr !== 'undefined') { this.xhr.responseType = 'arraybuffer'; } }); // req.on('error', callback); req.on('end', function () { if (req.xhr.status > 399) { deferred.reject(new Error(req.xhr.status)); } else { var contentType = req.xhr.getResponseHeader('content-type'); var response = req.xhr.response; // response is an arraybuffer if (contentType === 'application/json') { response = JSON.parse(UINT.uint8ArrayToString(new Uint8Array(response))); } self.logger.debug('getObject - result', {metadata: response}); deferred.resolve(response); } }); // TODO: Why is there an end here too? Isn't req.on('end',..) enough? req.end(function (err, result) { if (err) { deferred.reject(err); } else { self.logger.debug('getObject - result', {metadata: result}); deferred.resolve(result); } }); } return deferred.promise.nodeify(callback); }; /** * If running under nodejs and getting large objects use this method to pipe the downloaded * object to your provided writeStream. * @example * // Piping object to the filesystem.. * var writeStream = fs.createWriteStream('my.zip'); * * writeStream.on('error', function (err) { * // handle error * }); * * writeStream.on('finish', function () { * // my.zip exists at this point * }); * * blobClient.getStreamObject(metadataHash, writeStream); * * @param {string} metadataHash - hash of metadata for object. * @param {stream.Writable} writeStream - stream the requested data will be piped to. * @param {string} [subpath] - optional file-like path to sub-object if complex blob */ BlobClient.prototype.getStreamObject = function (metadataHash, writeStream, subpath) { this.logger.debug('getStreamObject', metadataHash, subpath); var req = superagent.get(this.getViewURL(metadataHash, subpath)); this._setAuthHeaders(req); if (typeof Buffer !== 'undefined') { // running on node req.agent(this.keepaliveAgent); req.pipe(writeStream); } else { throw new Error('streamObject only supported under nodejs, use getObject instead.'); } }; /** * Retrieves object from blob storage and parses the content as a string. * @param {string} metadataHash - hash of metadata for object. * @param {function} [callback] - if provided no promise will be returned. * * @return {external:Promise} On success the promise will be resolved with {string} &lt;b>contentString&lt;/b>.&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.getObjectAsString = function (metadataHash, callback) { var self = this; return self.getObject(metadataHash) .then(function (content) { if (typeof content === 'string') { // This does currently not happen.. return content; } else if (typeof Buffer !== 'undefined' &amp;&amp; content instanceof Buffer) { return UINT.uint8ArrayToString(new Uint8Array(content)); } else if (content instanceof ArrayBuffer) { return UINT.uint8ArrayToString(new Uint8Array(content)); } else if (content !== null &amp;&amp; typeof content === 'object') { return JSON.stringify(content); } else { throw new Error('Unknown content encountered: ' + content); } }) .nodeify(callback); }; /** * Retrieves object from blob storage and parses the content as a JSON. (Will resolve with error if not valid JSON.) * @param {string} metadataHash - hash of metadata for object. * @param {function} [callback] - if provided no promise will be returned. * * @return {external:Promise} On success the promise will be resolved with {object} &lt;b>contentJSON&lt;/b>.&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.getObjectAsJSON = function (metadataHash, callback) { var self = this; return self.getObject(metadataHash) .then(function (content) { if (typeof content === 'string') { // This does currently not happen.. return JSON.parse(content); } else if (typeof Buffer !== 'undefined' &amp;&amp; content instanceof Buffer) { return JSON.parse(UINT.uint8ArrayToString(new Uint8Array(content))); } else if (content instanceof ArrayBuffer) { return JSON.parse(UINT.uint8ArrayToString(new Uint8Array(content))); } else if (content !== null &amp;&amp; typeof content === 'object') { return content; } else { throw new Error('Unknown content encountered: ' + content); } }) .nodeify(callback); }; /** * Retrieves metadata from blob storage. * @param {string} metadataHash - hash of metadata. * @param {function} [callback] - if provided no promise will be returned. * * @return {external:Promise} On success the promise will be resolved with {object} &lt;b>metadata&lt;/b>.&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.getMetadata = function (metadataHash, callback) { var req = superagent.get(this.getMetadataURL(metadataHash)), deferred = Q.defer(), self = this; this.logger.debug('getMetadata', metadataHash); this._setAuthHeaders(req); if (typeof window === 'undefined') { req.agent(this.keepaliveAgent); } req.end(function (err, res) { if (err || res.status > 399) { deferred.reject(err || new Error(res.status)); } else { self.logger.debug('getMetadata', res.text); deferred.resolve(JSON.parse(res.text)); } }); return deferred.promise.nodeify(callback); }; /** * Creates a new artifact and adds it to array of artifacts of the instance. * @param {string} name - Name of artifact * @return {Artifact} */ BlobClient.prototype.createArtifact = function (name) { var artifact = new Artifact(name, this); this.artifacts.push(artifact); return artifact; }; /** * Retrieves the {@link Artifact} from the blob storage. * @param {hash} metadataHash - hash associated with the artifact. * @param {function} [callback] - if provided no promise will be returned. * * @return {external:Promise} On success the promise will be resolved with * {@link Artifact} &lt;b>artifact&lt;/b>.&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.getArtifact = function (metadataHash, callback) { // TODO: get info check if complex flag is set to true. // TODO: get info get name. var self = this, deferred = Q.defer(); this.logger.debug('getArtifact', metadataHash); this.getMetadata(metadataHash, function (err, info) { if (err) { deferred.reject(err); return; } self.logger.debug('getArtifact - return', {metadata: info}); if (info.contentType === BlobMetadata.CONTENT_TYPES.COMPLEX) { var artifact = new Artifact(info.name, self, info); self.artifacts.push(artifact); deferred.resolve(artifact); } else { deferred.reject(new Error('not supported contentType ' + JSON.stringify(info, null, 4))); } }); return deferred.promise.nodeify(callback); }; /** * Saves all the artifacts associated with the current instance. * @param {function} [callback] - if provided no promise will be returned. * * @return {external:Promise} On success the promise will be resolved with * {string[]} &lt;b>artifactHashes&lt;/b> (metadataHashes).&lt;br> * On error the promise will be rejected with {@link Error} &lt;b>error&lt;/b>. */ BlobClient.prototype.saveAllArtifacts = function (callback) { var promises = []; for (var i = 0; i &lt; this.artifacts.length; i += 1) { promises.push(this.artifacts[i].save()); } return Q.all(promises).nodeify(callback); }; /** * Converts bytes to a human readable string. * @param {number} - File size in bytes. * @param {boolean} [si] - If true decimal conversion will be used (by default binary is used). * @returns {string} */ BlobClient.prototype.getHumanSize = function (bytes, si) { var thresh = si ? 1000 : 1024, units = si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'], u = -1; if (bytes &lt; thresh) { return bytes + ' B'; } do { bytes = bytes / thresh; u += 1; } while (bytes >= thresh); return bytes.toFixed(1) + ' ' + units[u]; }; BlobClient.prototype.setToken = function (token) { this.webgmeToken = token; }; return BlobClient; }); </code></pre> </article> </section> </div> <nav> <h2><a href="index.html">Home</a></h2><h3>Modules</h3><ul><li><a href="Server_GMEAuth.html">Server:GMEAuth</a></li><li><a href="Server_SafeStorage.html">Server:SafeStorage</a></li><li><a href="Server_UserProject.html">Server:UserProject</a></li><li><a href="module-Core.html">Core</a></li><li><a href="module-Storage.html">Storage</a></li><li><a href="module-crosscuts.html">crosscuts</a></li><li><a href="module-serialization.html">serialization</a></li></ul><h3>Externals</h3><ul><li><a href="external-Promise.html">Promise</a></li></ul><h3>Classes</h3><ul><li><a href="AddOnBase.html">AddOnBase</a></li><li><a href="AddOnUpdateResult.html">AddOnUpdateResult</a></li><li><a href="Artifact.html">Artifact</a></li><li><a href="BlobClient.html">BlobClient</a></li><li><a href="BlobMetadata.html">BlobMetadata</a></li><li><a href="BlobRunPluginClient.html">BlobRunPluginClient</a></li><li><a href="Client.html">Client</a></li><li><a href="Core.html">Core</a></li><li><a href="ExecutorClient.html">ExecutorClient</a></li><li><a href="GMENode.html">GMENode</a></li><li><a href="GmeLogger.html">GmeLogger</a></li><li><a href="InterPluginResult.html">InterPluginResult</a></li><li><a href="JobInfo.html">JobInfo</a></li><li><a href="OutputInfo.html">OutputInfo</a></li><li><a href="PluginBase.html">PluginBase</a></li><li><a href="PluginConfig.html">PluginConfig</a></li><li><a href="PluginMessage.html">PluginMessage</a></li><li><a href="PluginNodeDescription.html">PluginNodeDescription</a></li><li><a href="PluginResult.html">PluginResult</a></li><li><a href="Project.html">Project</a></li><li><a href="ProjectInterface.html">ProjectInterface</a></li><li><a href="Server_GMEAuth-GMEAuth.html">GMEAuth</a></li><li><a href="Server_SafeStorage-SafeStorage.html">SafeStorage</a></li><li><a href="Server_UserProject-UserProject.html">UserProject</a></li><li><a href="WebsocketRouter.html">WebsocketRouter</a></li><li><a href="WebsocketRouterUser.html">WebsocketRouterUser</a></li></ul><h3>Events</h3><ul><li><a href="Client.html#event:BRANCH_CHANGED">BRANCH_CHANGED</a></li><li><a href="Client.html#event:BRANCH_CLOSED">BRANCH_CLOSED</a></li><li><a href="Client.html#event:BRANCH_OPENED">BRANCH_OPENED</a></li><li><a href="Client.html#event:BRANCH_STATUS_CHANGED">BRANCH_STATUS_CHANGED</a></li><li><a href="Client.html#event:CONNECTED_USERS_CHANGED">CONNECTED_USERS_CHANGED</a></li><li><a href="Client.html#event:NETWORK_STATUS_CHANGED">NETWORK_STATUS_CHANGED</a></li><li><a href="Client.html#event:NOTIFICATION">NOTIFICATION</a></li><li><a href="Client.html#event:PLUGIN_FINISHED">PLUGIN_FINISHED</a></li><li><a href="Client.html#event:PLUGIN_INITIATED">PLUGIN_INITIATED</a></li><li><a href="Client.html#event:PLUGIN_NOTIFICATION">PLUGIN_NOTIFICATION</a></li><li><a href="Client.html#event:PROJECT_CLOSED">PROJECT_CLOSED</a></li><li><a href="Client.html#event:PROJECT_OPENED">PROJECT_OPENED</a></li></ul><h3><a href="global.html">Global</a></h3> </nav> <br class="clear"> <footer> Documentation generated by <a href="https://github.com/jsdoc/jsdoc">JSDoc 4.0.2</a> on Fri Jun 21 2024 09:43:40 GMT-0400 (Eastern Daylight Time) </footer> <script> prettyPrint(); </script> <script src="scripts/linenumber.js"> </script> </body> </html>