mirror of
https://github.com/appium/appium.git
synced 2026-10-29 21:11:02 +08:00
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import type {HTTPHeaders} from '@appium/types';
|
|
import axios, {type AxiosBasicCredentials, type Method, type RawAxiosRequestConfig} from 'axios';
|
|
import FormData from 'form-data';
|
|
import Ftp from 'jsftp';
|
|
|
|
import {fs} from './fs';
|
|
import log from './logger';
|
|
import {Timer} from './timing';
|
|
import {isPlainObject, toReadableSizeString} from './util';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
|
|
const DEFAULT_FILE_FIELD_NAME = 'file';
|
|
|
|
/** Common options for {@linkcode uploadFile} and {@linkcode downloadFile}. */
|
|
export interface NetOptions {
|
|
/** Whether to log the actual download performance (e.g. timings and speed). Defaults to true. */
|
|
isMetered?: boolean;
|
|
/** Authentication credentials */
|
|
auth?: AuthCredentials;
|
|
}
|
|
|
|
/** Basic auth credentials; used by {@linkcode NetOptions}. */
|
|
export interface AuthCredentials {
|
|
/** Non-empty user name (or use `username` for axios-style) */
|
|
user?: string;
|
|
/** Non-empty password (or use `password` for axios-style) */
|
|
pass?: string;
|
|
username?: string;
|
|
password?: string;
|
|
}
|
|
|
|
/** Specific options for {@linkcode downloadFile}. */
|
|
export interface DownloadOptions extends NetOptions {
|
|
/** Request timeout in milliseconds; defaults to {@linkcode DEFAULT_TIMEOUT_MS} */
|
|
timeout?: number;
|
|
/** Request headers mapping */
|
|
headers?: Record<string, unknown>;
|
|
}
|
|
|
|
/** Options for {@linkcode uploadFile} when the remote uses the `http(s)` protocol. */
|
|
export interface HttpUploadOptions extends NetOptions {
|
|
/** Additional request headers */
|
|
headers?: HTTPHeaders;
|
|
/** HTTP method for file upload. Defaults to 'POST'. */
|
|
method?: Method;
|
|
/** Request timeout in milliseconds; defaults to {@linkcode DEFAULT_TIMEOUT_MS} */
|
|
timeout?: number;
|
|
/**
|
|
* Name of the form field containing the file. Any falsy value uses non-multipart upload.
|
|
* Defaults to 'file'.
|
|
*/
|
|
fileFieldName?: string;
|
|
/**
|
|
* Additional form fields. Only considered if `fileFieldName` is set.
|
|
*/
|
|
formFields?: Record<string, unknown> | [string, unknown][];
|
|
}
|
|
|
|
/**
|
|
* Options for {@linkcode uploadFile} when the remote uses the `ftp` protocol.
|
|
* @deprecated FTP upload via jsftp is deprecated and will be removed in a future major version.
|
|
* Use HTTP(S) upload instead.
|
|
*/
|
|
export interface FtpUploadOptions extends NetOptions {}
|
|
|
|
/** @deprecated Use {@linkcode FtpUploadOptions} instead. */
|
|
export type NotHttpUploadOptions = FtpUploadOptions;
|
|
|
|
type AuthLike = AuthCredentials | AxiosBasicCredentials;
|
|
|
|
type HttpRemoteUri = `http://${string}` | `https://${string}`;
|
|
type FtpRemoteUri = `ftp://${string}`;
|
|
|
|
/** Uploads the given file to a remote location via HTTP(S). */
|
|
export async function uploadFile(
|
|
localPath: string,
|
|
remoteUri: HttpRemoteUri,
|
|
uploadOptions?: HttpUploadOptions,
|
|
): Promise<void>;
|
|
/**
|
|
* Uploads the given file to a remote location via FTP.
|
|
* @deprecated FTP upload via jsftp is deprecated and will be removed in a future major version.
|
|
* Use HTTP(S) upload instead.
|
|
*/
|
|
export async function uploadFile(
|
|
localPath: string,
|
|
remoteUri: FtpRemoteUri,
|
|
uploadOptions?: FtpUploadOptions,
|
|
): Promise<void>;
|
|
export async function uploadFile(
|
|
localPath: string,
|
|
remoteUri: string,
|
|
uploadOptions?: HttpUploadOptions,
|
|
): Promise<void>;
|
|
export async function uploadFile(
|
|
localPath: string,
|
|
remoteUri: string,
|
|
uploadOptions: HttpUploadOptions | FtpUploadOptions = {},
|
|
): Promise<void> {
|
|
if (!(await fs.exists(localPath))) {
|
|
throw new Error(`'${localPath}' does not exist or is not accessible`);
|
|
}
|
|
|
|
const {isMetered = true} = uploadOptions;
|
|
const url = new URL(remoteUri);
|
|
const {size} = await fs.stat(localPath);
|
|
if (isMetered) {
|
|
log.info(`Uploading '${localPath}' of ${toReadableSizeString(size)} size to '${remoteUri}'`);
|
|
}
|
|
const timer = new Timer().start();
|
|
if (isHttpUploadOptions(uploadOptions, url)) {
|
|
// mirror the default applied in uploadFileToHttp: only an absent name becomes the multipart
|
|
// default, so an explicitly falsy one still means a raw body that needs Content-Length
|
|
const fileFieldName =
|
|
uploadOptions.fileFieldName === undefined ? DEFAULT_FILE_FIELD_NAME : uploadOptions.fileFieldName;
|
|
if (!fileFieldName) {
|
|
uploadOptions.headers = {
|
|
...(isPlainObject(uploadOptions.headers) ? uploadOptions.headers : {}),
|
|
'Content-Length': size,
|
|
};
|
|
}
|
|
await uploadFileToHttp(fs.createReadStream(localPath), url, uploadOptions);
|
|
} else if (isFtpUploadOptions(uploadOptions, url)) {
|
|
await uploadFileToFtp(fs.createReadStream(localPath), url, uploadOptions);
|
|
} else {
|
|
throw new Error(
|
|
`Cannot upload the file at '${localPath}' to '${remoteUri}'. ` +
|
|
`Unsupported remote protocol '${url.protocol}'. ` +
|
|
`Only http/https and ftp/ftps protocols are supported.`,
|
|
);
|
|
}
|
|
if (isMetered) {
|
|
log.info(
|
|
`Uploaded '${localPath}' of ${toReadableSizeString(size)} size in ` +
|
|
`${timer.getDuration().asSeconds.toFixed(3)}s`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// #region Private helpers
|
|
|
|
/**
|
|
* Downloads the given file via HTTP(S).
|
|
*
|
|
* @throws {Error} If download operation fails
|
|
*/
|
|
export async function downloadFile(
|
|
remoteUrl: string,
|
|
dstPath: string,
|
|
downloadOptions: DownloadOptions = {},
|
|
): Promise<void> {
|
|
const {isMetered = true, auth, timeout = DEFAULT_TIMEOUT_MS, headers} = downloadOptions;
|
|
|
|
const requestOpts: RawAxiosRequestConfig = {
|
|
url: remoteUrl,
|
|
responseType: 'stream',
|
|
timeout,
|
|
};
|
|
const axiosAuth = toAxiosAuth(auth);
|
|
if (axiosAuth) {
|
|
requestOpts.auth = axiosAuth;
|
|
}
|
|
if (isPlainObject(headers)) {
|
|
requestOpts.headers = headers as RawAxiosRequestConfig['headers'];
|
|
}
|
|
|
|
const timer = new Timer().start();
|
|
let responseLength: number;
|
|
try {
|
|
const writer = fs.createWriteStream(dstPath);
|
|
const {data: responseStream, headers: responseHeaders} = await axios(requestOpts);
|
|
responseLength = parseInt(String(responseHeaders['content-length'] ?? '0'), 10);
|
|
(responseStream as NodeJS.ReadableStream).pipe(writer);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
(responseStream as NodeJS.ReadableStream).once('error', reject);
|
|
writer.once('finish', () => resolve());
|
|
writer.once('error', (e: Error) => {
|
|
(responseStream as NodeJS.ReadableStream).unpipe(writer);
|
|
reject(e);
|
|
});
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Cannot download the file from ${remoteUrl}: ${message}`, {cause: err});
|
|
}
|
|
|
|
const {size} = await fs.stat(dstPath);
|
|
if (responseLength && size !== responseLength) {
|
|
await fs.rimraf(dstPath);
|
|
throw new Error(
|
|
`The size of the file downloaded from ${remoteUrl} (${size} bytes) ` +
|
|
`differs from the one in Content-Length response header (${responseLength} bytes)`,
|
|
);
|
|
}
|
|
if (isMetered) {
|
|
const secondsElapsed = timer.getDuration().asSeconds;
|
|
log.debug(
|
|
`${remoteUrl} (${toReadableSizeString(size)}) ` +
|
|
`has been downloaded to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`,
|
|
);
|
|
if (secondsElapsed >= 2) {
|
|
const bytesPerSec = Math.floor(size / secondsElapsed);
|
|
log.debug(`Approximate download speed: ${toReadableSizeString(bytesPerSec)}/s`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function toAxiosAuth(auth: AuthLike | undefined): AxiosBasicCredentials | null {
|
|
if (!auth || !isPlainObject(auth)) {
|
|
return null;
|
|
}
|
|
|
|
const username = 'username' in auth ? auth.username : auth.user;
|
|
const password = 'password' in auth ? auth.password : auth.pass;
|
|
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
|
|
return null;
|
|
}
|
|
return {username, password};
|
|
}
|
|
|
|
async function uploadFileToHttp(
|
|
localFileStream: NodeJS.ReadableStream,
|
|
parsedUri: URL,
|
|
uploadOptions: HttpUploadOptions = {},
|
|
): Promise<void> {
|
|
const {
|
|
method = 'POST',
|
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
headers,
|
|
auth,
|
|
fileFieldName = DEFAULT_FILE_FIELD_NAME,
|
|
formFields,
|
|
} = uploadOptions;
|
|
const {href} = parsedUri;
|
|
|
|
const requestOpts: RawAxiosRequestConfig = {
|
|
url: href,
|
|
method,
|
|
timeout,
|
|
maxContentLength: Infinity,
|
|
maxBodyLength: Infinity,
|
|
};
|
|
const axiosAuth = toAxiosAuth(auth);
|
|
if (axiosAuth) {
|
|
requestOpts.auth = axiosAuth;
|
|
}
|
|
if (fileFieldName) {
|
|
const form = new FormData();
|
|
if (formFields) {
|
|
let pairs: [string, unknown][] = [];
|
|
if (Array.isArray(formFields)) {
|
|
pairs = formFields as [string, unknown][];
|
|
} else if (isPlainObject(formFields)) {
|
|
pairs = Object.entries(formFields);
|
|
}
|
|
for (const [key, value] of pairs) {
|
|
if (key.toLowerCase() !== fileFieldName?.toLowerCase()) {
|
|
form.append(key, value as string | Buffer);
|
|
}
|
|
}
|
|
}
|
|
// AWS S3 POST upload requires this to be the last field; do not move before formFields.
|
|
form.append(fileFieldName, localFileStream);
|
|
requestOpts.headers = {
|
|
...(isPlainObject(headers) ? headers : {}),
|
|
...form.getHeaders(),
|
|
};
|
|
requestOpts.data = form;
|
|
} else {
|
|
if (isPlainObject(headers)) {
|
|
requestOpts.headers = headers;
|
|
}
|
|
requestOpts.data = localFileStream;
|
|
}
|
|
log.debug(
|
|
`Performing ${method} to ${href} with options (excluding data): ` +
|
|
JSON.stringify(
|
|
(() => {
|
|
const requestOptsWithoutData = {...requestOpts} as Record<string, unknown>;
|
|
delete requestOptsWithoutData.data;
|
|
return requestOptsWithoutData;
|
|
})(),
|
|
),
|
|
);
|
|
|
|
const {status, statusText} = await axios(requestOpts);
|
|
log.info(`Server response: ${status} ${statusText}`);
|
|
}
|
|
|
|
/** @deprecated FTP upload via jsftp is deprecated and will be removed in a future major version. */
|
|
async function uploadFileToFtp(
|
|
localFileStream: string | Buffer | NodeJS.ReadableStream,
|
|
parsedUri: URL,
|
|
uploadOptions: FtpUploadOptions = {},
|
|
): Promise<void> {
|
|
const {auth} = uploadOptions;
|
|
const {protocol, hostname, port, pathname} = parsedUri;
|
|
|
|
const ftpOpts: {host: string; port: number; user?: string; pass?: string} = {
|
|
host: hostname ?? '',
|
|
port: port !== undefined && port !== '' ? Number.parseInt(port, 10) : 21,
|
|
};
|
|
if (auth?.user && auth?.pass) {
|
|
ftpOpts.user = auth.user;
|
|
ftpOpts.pass = auth.pass;
|
|
}
|
|
log.debug(`${protocol.slice(0, -1)} upload options: ${JSON.stringify(ftpOpts)}`);
|
|
return await new Promise<void>((resolve, reject) => {
|
|
new Ftp(ftpOpts).put(localFileStream, pathname, (err) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function isHttpUploadOptions(opts: HttpUploadOptions | FtpUploadOptions, url: URL): opts is HttpUploadOptions {
|
|
try {
|
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the URL is FTP, i.e. the options are for FTP upload.
|
|
* @deprecated FTP upload via jsftp is deprecated and will be removed in a future major version.
|
|
*/
|
|
function isFtpUploadOptions(opts: HttpUploadOptions | FtpUploadOptions, url: URL): opts is FtpUploadOptions {
|
|
try {
|
|
return url.protocol === 'ftp:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// #endregion
|