UNPKG

@appium/base-driver

Version:

Base driver class for Appium drivers

680 lines (679 loc) 29.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.BASEDRIVER_VER = void 0; exports.configureApp = configureApp; exports.isPackageOrBundle = isPackageOrBundle; exports.duplicateKeys = duplicateKeys; exports.parseCapsArray = parseCapsArray; exports.generateDriverLogPrefix = generateDriverLogPrefix; const lodash_1 = __importDefault(require("lodash")); const path_1 = __importDefault(require("path")); const url_1 = __importDefault(require("url")); const logger_1 = __importDefault(require("./logger")); const support_1 = require("@appium/support"); const lru_cache_1 = require("lru-cache"); const async_lock_1 = __importDefault(require("async-lock")); const axios_1 = __importDefault(require("axios")); const bluebird_1 = __importDefault(require("bluebird")); // for compat with running tests transpiled and in-place exports.BASEDRIVER_VER = support_1.fs.readPackageJsonFrom(__dirname).version; const IPA_EXT = '.ipa'; const ZIP_EXTS = new Set(['.zip', IPA_EXT]); const ZIP_MIME_TYPES = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip']; const CACHED_APPS_MAX_AGE_MS = 1000 * 60 * toNaturalNumber(60 * 24, 'APPIUM_APPS_CACHE_MAX_AGE'); const MAX_CACHED_APPS = toNaturalNumber(1024, 'APPIUM_APPS_CACHE_MAX_ITEMS'); const HTTP_STATUS_NOT_MODIFIED = 304; const DEFAULT_REQ_HEADERS = Object.freeze({ 'user-agent': `Appium (BaseDriver v${exports.BASEDRIVER_VER})`, }); const AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC = 2; /** @type {LRUCache<string, import('@appium/types').CachedAppInfo>} */ const APPLICATIONS_CACHE = new lru_cache_1.LRUCache({ max: MAX_CACHED_APPS, ttl: CACHED_APPS_MAX_AGE_MS, // expire after 24 hours updateAgeOnGet: true, dispose: ({ fullPath }, app) => { logger_1.default.info(`The application '${app}' cached at '${fullPath}' has ` + `expired after ${CACHED_APPS_MAX_AGE_MS}ms`); if (fullPath) { support_1.fs.rimraf(fullPath); } }, noDisposeOnSet: true, }); const APPLICATIONS_CACHE_GUARD = new async_lock_1.default(); const SANITIZE_REPLACEMENT = '-'; const DEFAULT_BASENAME = 'appium-app'; const APP_DOWNLOAD_TIMEOUT_MS = 120 * 1000; process.on('exit', () => { if (APPLICATIONS_CACHE.size === 0) { return; } const appPaths = [...APPLICATIONS_CACHE.values()].map(({ fullPath }) => fullPath); logger_1.default.debug(`Performing cleanup of ${appPaths.length} cached ` + support_1.util.pluralize('application', appPaths.length)); for (const appPath of appPaths) { try { // @ts-ignore it's defined support_1.fs.rimrafSync(appPath); } catch (e) { logger_1.default.warn(e.message); } } }); /** * * @param {string} app * @param {string|string[]|import('@appium/types').ConfigureAppOptions} options */ async function configureApp(app, options = /** @type {import('@appium/types').ConfigureAppOptions} */ ({})) { if (!lodash_1.default.isString(app)) { // immediately shortcircuit if not given an app return; } let supportedAppExtensions; const onPostProcess = !lodash_1.default.isString(options) && !lodash_1.default.isArray(options) ? options.onPostProcess : undefined; const onDownload = !lodash_1.default.isString(options) && !lodash_1.default.isArray(options) ? options.onDownload : undefined; if (lodash_1.default.isString(options)) { supportedAppExtensions = [options]; } else if (lodash_1.default.isArray(options)) { supportedAppExtensions = options; } else if (lodash_1.default.isPlainObject(options)) { supportedAppExtensions = options.supportedExtensions; } if (lodash_1.default.isEmpty(supportedAppExtensions)) { throw new Error(`One or more supported app extensions must be provided`); } let newApp = app; const originalAppLink = app; let shouldUnzipApp = false; let packageHash = null; /** @type {import('axios').AxiosResponse['headers']|undefined} */ let headers = undefined; /** @type {RemoteAppProps} */ const remoteAppProps = { lastModified: null, immutable: false, maxAge: null, etag: null, }; const { protocol, pathname } = parseAppLink(app); const isUrl = isSupportedUrl(app); if (!isUrl && !path_1.default.isAbsolute(newApp)) { newApp = path_1.default.resolve(process.cwd(), newApp); logger_1.default.warn(`The current application path '${app}' is not absolute ` + `and has been rewritten to '${newApp}'. Consider using absolute paths rather than relative`); app = newApp; } const appCacheKey = toCacheKey(app); const cachedAppInfo = APPLICATIONS_CACHE.get(appCacheKey); if (cachedAppInfo) { logger_1.default.debug(`Cached app data: ${JSON.stringify(cachedAppInfo, null, 2)}`); } return await APPLICATIONS_CACHE_GUARD.acquire(appCacheKey, async () => { if (isUrl) { // Use the app from remote URL logger_1.default.info(`Using downloadable app '${newApp}'`); const reqHeaders = { ...DEFAULT_REQ_HEADERS, }; if (cachedAppInfo?.etag) { reqHeaders['if-none-match'] = cachedAppInfo.etag; } else if (cachedAppInfo?.lastModified) { reqHeaders['if-modified-since'] = cachedAppInfo.lastModified.toUTCString(); } logger_1.default.debug(`Request headers: ${JSON.stringify(reqHeaders)}`); let { headers, stream, status } = await queryAppLink(newApp, reqHeaders); logger_1.default.debug(`Response status: ${status}`); try { if (!lodash_1.default.isEmpty(headers)) { if (headers.etag) { logger_1.default.debug(`Etag: ${headers.etag}`); remoteAppProps.etag = headers.etag; } if (headers['last-modified']) { logger_1.default.debug(`Last-Modified: ${headers['last-modified']}`); remoteAppProps.lastModified = new Date(headers['last-modified']); } if (headers['cache-control']) { logger_1.default.debug(`Cache-Control: ${headers['cache-control']}`); remoteAppProps.immutable = /\bimmutable\b/i.test(headers['cache-control']); const maxAgeMatch = /\bmax-age=(\d+)\b/i.exec(headers['cache-control']); if (maxAgeMatch) { remoteAppProps.maxAge = parseInt(maxAgeMatch[1], 10); } } } if (cachedAppInfo && status === HTTP_STATUS_NOT_MODIFIED) { if (await isAppIntegrityOk(/** @type {string} */ (cachedAppInfo.fullPath), cachedAppInfo.integrity)) { logger_1.default.info(`Reusing previously downloaded application at '${cachedAppInfo.fullPath}'`); return verifyAppExtension(/** @type {string} */ (cachedAppInfo.fullPath), supportedAppExtensions); } logger_1.default.info(`The application at '${cachedAppInfo.fullPath}' does not exist anymore ` + `or its integrity has been damaged. Deleting it from the internal cache`); APPLICATIONS_CACHE.delete(appCacheKey); if (!stream.closed) { stream.destroy(); } ({ stream, headers, status } = await queryAppLink(newApp, { ...DEFAULT_REQ_HEADERS })); } let fileName = null; const basename = support_1.fs.sanitizeName(path_1.default.basename(decodeURIComponent(pathname ?? '')), { replacement: SANITIZE_REPLACEMENT, }); const extname = path_1.default.extname(basename); // to determine if we need to unzip the app, we have a number of places // to look: content type, content disposition, or the file extension if (ZIP_EXTS.has(extname)) { fileName = basename; shouldUnzipApp = true; } if (headers['content-type']) { const ct = headers['content-type']; logger_1.default.debug(`Content-Type: ${ct}`); // the filetype may not be obvious for certain urls, so check the mime type too if (ZIP_MIME_TYPES.some((mimeType) => new RegExp(`\\b${lodash_1.default.escapeRegExp(mimeType)}\\b`).test(ct))) { if (!fileName) { fileName = `${DEFAULT_BASENAME}.zip`; } shouldUnzipApp = true; } } if (headers['content-disposition'] && /^attachment/i.test(headers['content-disposition'])) { logger_1.default.debug(`Content-Disposition: ${headers['content-disposition']}`); const match = /filename="([^"]+)/i.exec(headers['content-disposition']); if (match) { fileName = support_1.fs.sanitizeName(match[1], { replacement: SANITIZE_REPLACEMENT, }); shouldUnzipApp = shouldUnzipApp || ZIP_EXTS.has(path_1.default.extname(fileName)); } } if (!fileName) { // assign the default file name and the extension if none has been detected const resultingName = basename ? basename.substring(0, basename.length - extname.length) : DEFAULT_BASENAME; let resultingExt = extname; if (!supportedAppExtensions.includes(resultingExt)) { logger_1.default.info(`The current file extension '${resultingExt}' is not supported. ` + `Defaulting to '${lodash_1.default.first(supportedAppExtensions)}'`); resultingExt = /** @type {string} */ (lodash_1.default.first(supportedAppExtensions)); } fileName = `${resultingName}${resultingExt}`; } newApp = onDownload ? await onDownload({ url: originalAppLink, headers: /** @type {import('@appium/types').HTTPHeaders} */ (lodash_1.default.clone(headers)), stream, }) : await fetchApp(stream, await support_1.tempDir.path({ prefix: fileName, suffix: '', })); } finally { if (!stream.closed) { stream.destroy(); } } } else if (await support_1.fs.exists(newApp)) { // Use the local app logger_1.default.info(`Using local app '${newApp}'`); shouldUnzipApp = ZIP_EXTS.has(path_1.default.extname(newApp)); } else { let errorMessage = `The application at '${newApp}' does not exist or is not accessible`; // protocol value for 'C:\\temp' is 'c:', so we check the length as well if (lodash_1.default.isString(protocol) && protocol.length > 2) { errorMessage = `The protocol '${protocol}' used in '${newApp}' is not supported. ` + `Only http: and https: protocols are supported`; } throw new Error(errorMessage); } const isPackageAFile = (await support_1.fs.stat(newApp)).isFile(); if (isPackageAFile) { packageHash = await calculateFileIntegrity(newApp); } if (isPackageAFile && shouldUnzipApp && !lodash_1.default.isFunction(onPostProcess)) { const archivePath = newApp; if (packageHash === cachedAppInfo?.packageHash) { const fullPath = cachedAppInfo?.fullPath; if (await isAppIntegrityOk(/** @type {string} */ (fullPath), cachedAppInfo?.integrity)) { if (archivePath !== app) { await support_1.fs.rimraf(archivePath); } logger_1.default.info(`Will reuse previously cached application at '${fullPath}'`); return verifyAppExtension(/** @type {string} */ (fullPath), supportedAppExtensions); } logger_1.default.info(`The application at '${fullPath}' does not exist anymore ` + `or its integrity has been damaged. Deleting it from the cache`); APPLICATIONS_CACHE.delete(appCacheKey); } const tmpRoot = await support_1.tempDir.openDir(); try { newApp = await unzipApp(archivePath, tmpRoot, supportedAppExtensions); } finally { if (newApp !== archivePath && archivePath !== app) { await support_1.fs.rimraf(archivePath); } } logger_1.default.info(`Unzipped local app to '${newApp}'`); } const storeAppInCache = async (appPathToCache) => { const cachedFullPath = cachedAppInfo?.fullPath; if (cachedFullPath && cachedFullPath !== appPathToCache) { await support_1.fs.rimraf(cachedFullPath); } const integrity = {}; if ((await support_1.fs.stat(appPathToCache)).isDirectory()) { integrity.folder = await calculateFolderIntegrity(appPathToCache); } else { integrity.file = await calculateFileIntegrity(appPathToCache); } APPLICATIONS_CACHE.set(appCacheKey, { ...remoteAppProps, timestamp: Date.now(), packageHash, integrity, fullPath: appPathToCache, }); return appPathToCache; }; if (lodash_1.default.isFunction(onPostProcess)) { const result = await onPostProcess( /** @type {import('@appium/types').PostProcessOptions<import('axios').AxiosResponseHeaders>} */ ({ cachedAppInfo: lodash_1.default.clone(cachedAppInfo), isUrl, originalAppLink, headers: lodash_1.default.clone(headers), appPath: newApp, })); return !result?.appPath || app === result?.appPath || !(await support_1.fs.exists(result?.appPath)) ? newApp : await storeAppInCache(result.appPath); } verifyAppExtension(newApp, supportedAppExtensions); return appCacheKey !== toCacheKey(newApp) && (packageHash || lodash_1.default.values(remoteAppProps).some(Boolean)) ? await storeAppInCache(newApp) : newApp; }); } /** * @param {string} app * @returns {boolean} */ function isPackageOrBundle(app) { return /^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/.test(app); } /** * Finds all instances 'firstKey' and create a duplicate with the key 'secondKey', * Do the same thing in reverse. If we find 'secondKey', create a duplicate with the key 'firstKey'. * * This will cause keys to be overwritten if the object contains 'firstKey' and 'secondKey'. * @param {*} input Any type of input * @param {String} firstKey The first key to duplicate * @param {String} secondKey The second key to duplicate */ function duplicateKeys(input, firstKey, secondKey) { // If array provided, recursively call on all elements if (lodash_1.default.isArray(input)) { return input.map((item) => duplicateKeys(item, firstKey, secondKey)); } // If object, create duplicates for keys and then recursively call on values if (lodash_1.default.isPlainObject(input)) { const resultObj = {}; for (let [key, value] of lodash_1.default.toPairs(input)) { const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey); if (key === firstKey) { resultObj[secondKey] = recursivelyCalledValue; } else if (key === secondKey) { resultObj[firstKey] = recursivelyCalledValue; } resultObj[key] = recursivelyCalledValue; } return resultObj; } // Base case. Return primitives without doing anything. return input; } /** * Takes a desired capability and tries to JSON.parse it as an array, * and either returns the parsed array or a singleton array. * * @param {string|Array<String>} cap A desired capability */ function parseCapsArray(cap) { if (lodash_1.default.isArray(cap)) { return cap; } let parsedCaps; try { parsedCaps = JSON.parse(cap); if (lodash_1.default.isArray(parsedCaps)) { return parsedCaps; } } catch (e) { logger_1.default.warn(`Failed to parse capability as JSON array: ${e.message}`); } if (lodash_1.default.isString(cap)) { return [cap]; } throw new Error(`must provide a string or JSON Array; received ${cap}`); } /** * Generate a string that uniquely describes driver instance * * @param {object} obj driver instance * @param {string?} [sessionId=null] session identifier (if exists). * This parameter is deprecated and is not used. * @returns {string} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function generateDriverLogPrefix(obj, sessionId = null) { return `${obj.constructor.name}@${support_1.node.getObjectId(obj).substring(0, 4)}`; } /** * Sends a HTTP GET query to fetch the app with caching enabled. * Follows https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching * * @param {string} appLink The URL to download an app from * @param {import('axios').RawAxiosRequestHeaders} reqHeaders Additional HTTP request headers * @returns {Promise<RemoteAppData>} */ async function queryAppLink(appLink, reqHeaders) { const { href, auth } = url_1.default.parse(appLink); const axiosUrl = auth ? href.replace(`${auth}@`, '') : href; /** @type {import('axios').AxiosBasicCredentials|undefined} */ const axiosAuth = auth ? { username: auth.substring(0, auth.indexOf(':')), password: auth.substring(auth.indexOf(':') + 1), } : undefined; /** * @type {import('axios').RawAxiosRequestConfig} */ const requestOpts = { url: axiosUrl, auth: axiosAuth, responseType: 'stream', timeout: APP_DOWNLOAD_TIMEOUT_MS, validateStatus: (status) => (status >= 200 && status < 300) || status === HTTP_STATUS_NOT_MODIFIED, headers: reqHeaders, }; try { const { data: stream, headers, status } = await (0, axios_1.default)(requestOpts); return { stream, headers, status, }; } catch (err) { throw new Error(`Cannot download the app from ${axiosUrl}: ${err.message}`); } } /** * Retrieves app payload from the given stream. Also meters the download performance. * * @param {import('stream').Readable} srcStream The incoming stream * @param {string} dstPath The target file path to be written * @returns {Promise<string>} The same dstPath * @throws {Error} If there was a failure while downloading the file */ async function fetchApp(srcStream, dstPath) { const timer = new support_1.timing.Timer().start(); try { const writer = support_1.fs.createWriteStream(dstPath); srcStream.pipe(writer); await new bluebird_1.default((resolve, reject) => { srcStream.once('error', reject); writer.once('finish', resolve); writer.once('error', (e) => { srcStream.unpipe(writer); reject(e); }); }); } catch (err) { throw new Error(`Cannot fetch the application: ${err.message}`); } const secondsElapsed = timer.getDuration().asSeconds; const { size } = await support_1.fs.stat(dstPath); logger_1.default.debug(`The application (${support_1.util.toReadableSizeString(size)}) ` + `has been downloaded to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`); // it does not make much sense to approximate the speed for short downloads if (secondsElapsed >= AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC) { const bytesPerSec = Math.floor(size / secondsElapsed); logger_1.default.debug(`Approximate download speed: ${support_1.util.toReadableSizeString(bytesPerSec)}/s`); } return dstPath; } /** * Extracts the bundle from an archive into the given folder * * @param {string} zipPath Full path to the archive containing the bundle * @param {string} dstRoot Full path to the folder where the extracted bundle * should be placed * @param {Array<string>|string} supportedAppExtensions The list of extensions * the target application bundle supports, for example ['.apk', '.apks'] for * Android packages * @returns {Promise<string>} Full path to the bundle in the destination folder * @throws {Error} If the given archive is invalid or no application bundles * have been found inside */ async function unzipApp(zipPath, dstRoot, supportedAppExtensions) { await support_1.zip.assertValidZip(zipPath); if (!lodash_1.default.isArray(supportedAppExtensions)) { supportedAppExtensions = [supportedAppExtensions]; } const tmpRoot = await support_1.tempDir.openDir(); try { logger_1.default.debug(`Unzipping '${zipPath}'`); const timer = new support_1.timing.Timer().start(); const useSystemUnzip = isEnvOptionEnabled('APPIUM_PREFER_SYSTEM_UNZIP', true); /** * Attempt to use use the system `unzip` (e.g., `/usr/bin/unzip`) due * to the significant performance improvement it provides over the native * JS "unzip" implementation. * @type {import('@appium/support/lib/zip').ExtractAllOptions} */ const extractionOpts = { useSystemUnzip }; // https://github.com/appium/appium/issues/14100 if (path_1.default.extname(zipPath) === IPA_EXT) { logger_1.default.debug(`Enforcing UTF-8 encoding on the extracted file names for '${path_1.default.basename(zipPath)}'`); extractionOpts.fileNamesEncoding = 'utf8'; } await support_1.zip.extractAllTo(zipPath, tmpRoot, extractionOpts); const globPattern = `**/*.+(${supportedAppExtensions .map((ext) => ext.replace(/^\./, '')) .join('|')})`; const sortedBundleItems = (await support_1.fs.glob(globPattern, { cwd: tmpRoot, // Get the top level match })).sort((a, b) => a.split(path_1.default.sep).length - b.split(path_1.default.sep).length); if (lodash_1.default.isEmpty(sortedBundleItems)) { throw logger_1.default.errorWithException(`App unzipped OK, but we could not find any '${supportedAppExtensions}' ` + support_1.util.pluralize('bundle', supportedAppExtensions.length, false) + ` in it. Make sure your archive contains at least one package having ` + `'${supportedAppExtensions}' ${support_1.util.pluralize('extension', supportedAppExtensions.length, false)}`); } logger_1.default.debug(`Extracted ${support_1.util.pluralize('bundle item', sortedBundleItems.length, true)} ` + `from '${zipPath}' in ${Math.round(timer.getDuration().asMilliSeconds)}ms: ${sortedBundleItems}`); const matchedBundle = /** @type {string} */ (lodash_1.default.first(sortedBundleItems)); logger_1.default.info(`Assuming '${matchedBundle}' is the correct bundle`); const dstPath = path_1.default.resolve(dstRoot, path_1.default.basename(matchedBundle)); await support_1.fs.mv(path_1.default.resolve(tmpRoot, matchedBundle), dstPath, { mkdirp: true }); return dstPath; } finally { await support_1.fs.rimraf(tmpRoot); } } /** * Transforms the given app link to the cache key. * This is necessary to properly cache apps * having the same address, but different query strings, * for example ones stored in S3 using presigned URLs. * * @param {string} app App link. * @returns {string} Transformed app link or the original arg if * no transfromation is needed. */ function toCacheKey(app) { if (!isEnvOptionEnabled('APPIUM_APPS_CACHE_IGNORE_URL_QUERY') || !isSupportedUrl(app)) { return app; } try { const { href, search } = parseAppLink(app); if (href && search) { return href.replace(search, ''); } if (href) { return href; } } catch { } return app; } /** * Safely parses the given app link to a URL object * * @param {string} appLink * @returns {URL|import('@appium/types').StringRecord} Parsed URL object * or an empty object if the parsing has failed */ function parseAppLink(appLink) { try { return new URL(appLink); } catch { return {}; } } /** * Checks whether we can threat the given app link * as a URL, * * @param {string} app * @returns {boolean} True if app is a supported URL */ function isSupportedUrl(app) { try { const { protocol } = parseAppLink(app); return ['http:', 'https:'].includes(protocol); } catch { return false; } } /** * Check if the given environment option is enabled * * @param {string} optionName Option name * @param {boolean|null} [defaultValue=null] The value to return if the given env value * is not set explcitly * @returns {boolean} True if the option is enabled */ function isEnvOptionEnabled(optionName, defaultValue = null) { const value = process.env[optionName]; if (!lodash_1.default.isNull(defaultValue) && lodash_1.default.isEmpty(value)) { return defaultValue; } return !lodash_1.default.isEmpty(value) && !['0', 'false', 'no'].includes(lodash_1.default.toLower(value)); } /** * * @param {string} [envVarName] * @param {number} defaultValue * @returns {number} */ function toNaturalNumber(defaultValue, envVarName) { if (!envVarName || lodash_1.default.isUndefined(process.env[envVarName])) { return defaultValue; } const num = parseInt(`${process.env[envVarName]}`, 10); return num > 0 ? num : defaultValue; } /** * @param {string} app * @param {string[]} supportedAppExtensions * @returns {string} */ function verifyAppExtension(app, supportedAppExtensions) { if (supportedAppExtensions.map(lodash_1.default.toLower).includes(lodash_1.default.toLower(path_1.default.extname(app)))) { return app; } throw new Error(`New app path '${app}' did not have ` + `${support_1.util.pluralize('extension', supportedAppExtensions.length, false)}: ` + supportedAppExtensions); } /** * @param {string} folderPath * @returns {Promise<number>} */ async function calculateFolderIntegrity(folderPath) { return (await support_1.fs.glob('**/*', { cwd: folderPath })).length; } /** * @param {string} filePath * @returns {Promise<string>} */ async function calculateFileIntegrity(filePath) { return await support_1.fs.hash(filePath); } /** * @param {string} currentPath * @param {import('@appium/types').StringRecord} expectedIntegrity * @returns {Promise<boolean>} */ async function isAppIntegrityOk(currentPath, expectedIntegrity = {}) { if (!(await support_1.fs.exists(currentPath))) { return false; } // Folder integrity check is simple: // Verify the previous amount of files is not greater than the current one. // We don't want to use equality comparison because of an assumption that the OS might // create some unwanted service files/cached inside of that folder or its subfolders. // Ofc, validating the hash sum of each file (or at least of file path) would be much // more precise, but we don't need to be very precise here and also don't want to // overuse RAM and have a performance drop. return (await support_1.fs.stat(currentPath)).isDirectory() ? (await calculateFolderIntegrity(currentPath)) >= expectedIntegrity?.folder : (await calculateFileIntegrity(currentPath)) === expectedIntegrity?.file; } /** @type {import('@appium/types').DriverHelpers} */ exports.default = { configureApp, isPackageOrBundle, duplicateKeys, parseCapsArray, generateDriverLogPrefix, }; /** * @typedef RemoteAppProps * @property {Date?} lastModified * @property {boolean} immutable * @property {number?} maxAge * @property {string?} etag */ /** * @typedef RemoteAppData Properties of the remote application (e.g. GET HTTP response) to be downloaded. * @property {number} status The HTTP status of the response * @property {import('stream').Readable} stream The HTTP response body represented as readable stream * @property {import('axios').RawAxiosResponseHeaders | import('axios').AxiosResponseHeaders} headers HTTP response headers */ //# sourceMappingURL=helpers.js.map