@appium/support 7.0.5 → 7.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/build/lib/console.d.ts +42 -88
- package/build/lib/console.d.ts.map +1 -1
- package/build/lib/console.js +20 -80
- package/build/lib/console.js.map +1 -1
- package/build/lib/doctor.d.ts +6 -18
- package/build/lib/doctor.d.ts.map +1 -1
- package/build/lib/doctor.js +0 -15
- package/build/lib/doctor.js.map +1 -1
- package/build/lib/env.d.ts +14 -20
- package/build/lib/env.d.ts.map +1 -1
- package/build/lib/env.js +13 -50
- package/build/lib/env.js.map +1 -1
- package/build/lib/fs.d.ts +109 -148
- package/build/lib/fs.d.ts.map +1 -1
- package/build/lib/fs.js +88 -188
- package/build/lib/fs.js.map +1 -1
- package/build/lib/image-util.d.ts +7 -6
- package/build/lib/image-util.d.ts.map +1 -1
- package/build/lib/image-util.js +9 -6
- package/build/lib/image-util.js.map +1 -1
- package/build/lib/index.d.ts +19 -17
- package/build/lib/index.d.ts.map +1 -1
- package/build/lib/logger.d.ts +1 -1
- package/build/lib/logger.d.ts.map +1 -1
- package/build/lib/logger.js +1 -1
- package/build/lib/logger.js.map +1 -1
- package/build/lib/logging.d.ts +7 -15
- package/build/lib/logging.d.ts.map +1 -1
- package/build/lib/logging.js +36 -62
- package/build/lib/logging.js.map +1 -1
- package/build/lib/mjpeg.d.ts +19 -56
- package/build/lib/mjpeg.d.ts.map +1 -1
- package/build/lib/mjpeg.js +53 -76
- package/build/lib/mjpeg.js.map +1 -1
- package/build/lib/mkdirp.d.ts +4 -1
- package/build/lib/mkdirp.d.ts.map +1 -1
- package/build/lib/mkdirp.js +1 -2
- package/build/lib/mkdirp.js.map +1 -1
- package/build/lib/net.d.ts +52 -90
- package/build/lib/net.d.ts.map +1 -1
- package/build/lib/net.js +104 -193
- package/build/lib/net.js.map +1 -1
- package/build/lib/node.d.ts +16 -17
- package/build/lib/node.d.ts.map +1 -1
- package/build/lib/node.js +106 -111
- package/build/lib/node.js.map +1 -1
- package/build/lib/npm.d.ts +65 -86
- package/build/lib/npm.d.ts.map +1 -1
- package/build/lib/npm.js +59 -117
- package/build/lib/npm.js.map +1 -1
- package/build/lib/plist.d.ts +36 -29
- package/build/lib/plist.d.ts.map +1 -1
- package/build/lib/plist.js +62 -59
- package/build/lib/plist.js.map +1 -1
- package/build/lib/process.d.ts +19 -2
- package/build/lib/process.d.ts.map +1 -1
- package/build/lib/process.js +24 -7
- package/build/lib/process.js.map +1 -1
- package/build/lib/system.d.ts +41 -6
- package/build/lib/system.d.ts.map +1 -1
- package/build/lib/system.js +46 -11
- package/build/lib/system.js.map +1 -1
- package/build/lib/tempdir.d.ts +26 -49
- package/build/lib/tempdir.d.ts.map +1 -1
- package/build/lib/tempdir.js +41 -73
- package/build/lib/tempdir.js.map +1 -1
- package/build/lib/timing.d.ts +28 -22
- package/build/lib/timing.d.ts.map +1 -1
- package/build/lib/timing.js +16 -17
- package/build/lib/timing.js.map +1 -1
- package/build/lib/util.d.ts +164 -181
- package/build/lib/util.d.ts.map +1 -1
- package/build/lib/util.js +193 -247
- package/build/lib/util.js.map +1 -1
- package/build/lib/zip.d.ts +81 -139
- package/build/lib/zip.d.ts.map +1 -1
- package/build/lib/zip.js +210 -258
- package/build/lib/zip.js.map +1 -1
- package/lib/console.ts +139 -0
- package/lib/{doctor.js → doctor.ts} +6 -20
- package/lib/{env.js → env.ts} +31 -59
- package/lib/fs.ts +453 -0
- package/lib/image-util.ts +40 -0
- package/lib/index.ts +1 -0
- package/lib/{logger.js → logger.ts} +1 -1
- package/lib/logging.ts +157 -0
- package/lib/mjpeg.ts +186 -0
- package/lib/{mkdirp.js → mkdirp.ts} +2 -2
- package/lib/net.ts +305 -0
- package/lib/{node.js → node.ts} +134 -133
- package/lib/npm.ts +291 -0
- package/lib/plist.ts +187 -0
- package/lib/process.ts +62 -0
- package/lib/system.ts +95 -0
- package/lib/tempdir.ts +115 -0
- package/lib/{timing.js → timing.ts} +28 -33
- package/lib/util.ts +561 -0
- package/lib/{zip.js → zip.ts} +341 -296
- package/package.json +20 -22
- package/tsconfig.json +3 -5
- package/index.js +0 -1
- package/lib/console.js +0 -173
- package/lib/fs.js +0 -496
- package/lib/image-util.js +0 -32
- package/lib/logging.js +0 -145
- package/lib/mjpeg.js +0 -207
- package/lib/net.js +0 -336
- package/lib/npm.js +0 -310
- package/lib/plist.js +0 -182
- package/lib/process.js +0 -46
- package/lib/system.js +0 -48
- package/lib/tempdir.js +0 -131
- package/lib/util.js +0 -584
package/lib/mjpeg.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
import log from './logger';
|
|
3
|
+
import B from 'bluebird';
|
|
4
|
+
import {requireSharp} from './image-util';
|
|
5
|
+
import {Writable, type WritableOptions, type Readable} from 'node:stream';
|
|
6
|
+
import {requirePackage} from './node';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
|
|
9
|
+
/** Constructor for mjpeg-consumer (lazy-loaded) */
|
|
10
|
+
type MJpegConsumerConstructor = new () => NodeJS.ReadWriteStream;
|
|
11
|
+
|
|
12
|
+
let MJpegConsumer: MJpegConsumerConstructor | null = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @throws {Error} If `mjpeg-consumer` module is not installed or cannot be loaded
|
|
16
|
+
*/
|
|
17
|
+
async function initMJpegConsumer(): Promise<MJpegConsumerConstructor> {
|
|
18
|
+
if (!MJpegConsumer) {
|
|
19
|
+
try {
|
|
20
|
+
MJpegConsumer = (await requirePackage('mjpeg-consumer')) as MJpegConsumerConstructor;
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'mjpeg-consumer module is required to use MJPEG-over-HTTP features. ' +
|
|
24
|
+
'Please install it first (npm i -g mjpeg-consumer) and restart Appium.'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return MJpegConsumer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MJPEG_SERVER_TIMEOUT_MS = 10000;
|
|
32
|
+
|
|
33
|
+
/** Class which stores the last bit of data streamed into it */
|
|
34
|
+
export class MJpegStream extends Writable {
|
|
35
|
+
readonly errorHandler: (err: Error) => void;
|
|
36
|
+
readonly url: string;
|
|
37
|
+
/** Number of JPEG frames received so far (reset on {@linkcode clear}). Use stream['updateCount'] in tests if needed. */
|
|
38
|
+
private updateCount = 0;
|
|
39
|
+
/** Last received JPEG chunk (base64 via {@linkcode lastChunkBase64}); `null` when no data yet or after {@linkcode clear}/stop. Use stream['lastChunk'] in tests if needed. */
|
|
40
|
+
private lastChunk: Buffer | null = null;
|
|
41
|
+
private registerStartSuccess: (() => void) | null = null;
|
|
42
|
+
private registerStartFailure: ((err: Error) => void) | null = null;
|
|
43
|
+
private responseStream: Readable | null = null;
|
|
44
|
+
private consumer: NodeJS.ReadWriteStream | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param mJpegUrl - URL of MJPEG-over-HTTP stream
|
|
48
|
+
* @param errorHandler - additional function that will be called in the case of any errors
|
|
49
|
+
* @param options - Options to pass to the Writable constructor
|
|
50
|
+
*/
|
|
51
|
+
constructor(
|
|
52
|
+
mJpegUrl: string,
|
|
53
|
+
errorHandler: (err: Error) => void = _.noop,
|
|
54
|
+
options: WritableOptions = {}
|
|
55
|
+
) {
|
|
56
|
+
super(options);
|
|
57
|
+
this.errorHandler = errorHandler;
|
|
58
|
+
this.url = mJpegUrl;
|
|
59
|
+
this.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get lastChunkBase64(): string | null {
|
|
63
|
+
const lastChunk = this.lastChunk;
|
|
64
|
+
if (lastChunk && !_.isEmpty(lastChunk) && _.isBuffer(lastChunk)) {
|
|
65
|
+
return lastChunk.toString('base64');
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async lastChunkPNG(): Promise<Buffer | null> {
|
|
71
|
+
const chunk = this.lastChunk;
|
|
72
|
+
if (!chunk || _.isEmpty(chunk) || !_.isBuffer(chunk)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return await requireSharp()(chunk).png().toBuffer();
|
|
77
|
+
} catch (err: any) {
|
|
78
|
+
log.warn(`Cannot convert MJPEG chunk to PNG: ${err.message}`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async lastChunkPNGBase64(): Promise<string | null> {
|
|
84
|
+
const png = await this.lastChunkPNG();
|
|
85
|
+
return png ? png.toString('base64') : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
clear(): void {
|
|
89
|
+
this.registerStartSuccess = null;
|
|
90
|
+
this.registerStartFailure = null;
|
|
91
|
+
this.responseStream = null;
|
|
92
|
+
this.consumer = null;
|
|
93
|
+
this.lastChunk = null;
|
|
94
|
+
this.updateCount = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async start(serverTimeout = MJPEG_SERVER_TIMEOUT_MS): Promise<void> {
|
|
98
|
+
this.stop();
|
|
99
|
+
|
|
100
|
+
const Consumer = await initMJpegConsumer();
|
|
101
|
+
this.consumer = new Consumer();
|
|
102
|
+
const url = this.url;
|
|
103
|
+
try {
|
|
104
|
+
this.responseStream = (
|
|
105
|
+
await axios({
|
|
106
|
+
url,
|
|
107
|
+
responseType: 'stream',
|
|
108
|
+
timeout: serverTimeout,
|
|
109
|
+
})
|
|
110
|
+
).data as Readable;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
let message: string;
|
|
113
|
+
if (e && typeof e === 'object' && 'response' in e) {
|
|
114
|
+
message = JSON.stringify((e as {response: unknown}).response);
|
|
115
|
+
} else if (e instanceof Error) {
|
|
116
|
+
message = e.message;
|
|
117
|
+
} else {
|
|
118
|
+
message = String(e);
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Cannot connect to the MJPEG stream at ${url}. Original error: ${message}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const onErr = (err: Error) => {
|
|
126
|
+
this.lastChunk = null;
|
|
127
|
+
log.error(`Error getting MJpeg screenshot chunk: ${err.message}`);
|
|
128
|
+
this.errorHandler(err);
|
|
129
|
+
if (this.registerStartFailure) {
|
|
130
|
+
this.registerStartFailure(err);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const onClose = () => {
|
|
134
|
+
log.debug(`The connection to MJPEG server at ${url} has been closed`);
|
|
135
|
+
this.lastChunk = null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// TODO: replace with native Promises in Appium 4
|
|
139
|
+
const startPromise = new B<void>((res, rej) => {
|
|
140
|
+
this.registerStartSuccess = res;
|
|
141
|
+
this.registerStartFailure = rej;
|
|
142
|
+
}).timeout(
|
|
143
|
+
serverTimeout,
|
|
144
|
+
`Waited ${serverTimeout}ms but the MJPEG server never sent any images`
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
(this.responseStream as Readable & {pipe<T extends Writable>(dest: T): T})
|
|
148
|
+
.once('close', onClose)
|
|
149
|
+
.on('error', onErr)
|
|
150
|
+
.pipe(this.consumer as unknown as Writable)
|
|
151
|
+
.pipe(this);
|
|
152
|
+
|
|
153
|
+
await startPromise;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
stop(): void {
|
|
157
|
+
if (this.consumer) {
|
|
158
|
+
this.consumer.unpipe(this);
|
|
159
|
+
}
|
|
160
|
+
if (this.responseStream) {
|
|
161
|
+
if (this.consumer) {
|
|
162
|
+
this.responseStream.unpipe(this.consumer);
|
|
163
|
+
}
|
|
164
|
+
this.responseStream.destroy();
|
|
165
|
+
}
|
|
166
|
+
this.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* eslint-disable @typescript-eslint/no-unused-vars -- Writable.write signature requires encoding and callback */
|
|
170
|
+
/* eslint-disable promise/prefer-await-to-callbacks -- Node Writable.write is callback-based */
|
|
171
|
+
override write(
|
|
172
|
+
data: Buffer | string | Uint8Array,
|
|
173
|
+
encoding?: BufferEncoding | ((error: Error | null) => void),
|
|
174
|
+
callback?: (error: Error | null) => void
|
|
175
|
+
): boolean {
|
|
176
|
+
/* eslint-enable @typescript-eslint/no-unused-vars */
|
|
177
|
+
/* eslint-enable promise/prefer-await-to-callbacks */
|
|
178
|
+
this.lastChunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
179
|
+
this.updateCount++;
|
|
180
|
+
if (this.registerStartSuccess) {
|
|
181
|
+
this.registerStartSuccess();
|
|
182
|
+
this.registerStartSuccess = null;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/lib/net.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
import {fs} from './fs';
|
|
3
|
+
import {toReadableSizeString} from './util';
|
|
4
|
+
import log from './logger';
|
|
5
|
+
import Ftp from 'jsftp';
|
|
6
|
+
import {Timer} from './timing';
|
|
7
|
+
import axios, {type AxiosBasicCredentials, type Method, type RawAxiosRequestConfig} from 'axios';
|
|
8
|
+
import FormData from 'form-data';
|
|
9
|
+
import type {HTTPHeaders} from '@appium/types';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/** Common options for {@linkcode uploadFile} and {@linkcode downloadFile}. */
|
|
14
|
+
export interface NetOptions {
|
|
15
|
+
/** Whether to log the actual download performance (e.g. timings and speed). Defaults to true. */
|
|
16
|
+
isMetered?: boolean;
|
|
17
|
+
/** Authentication credentials */
|
|
18
|
+
auth?: AuthCredentials;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Basic auth credentials; used by {@linkcode NetOptions}. */
|
|
22
|
+
export interface AuthCredentials {
|
|
23
|
+
/** Non-empty user name (or use `username` for axios-style) */
|
|
24
|
+
user?: string;
|
|
25
|
+
/** Non-empty password (or use `password` for axios-style) */
|
|
26
|
+
pass?: string;
|
|
27
|
+
username?: string;
|
|
28
|
+
password?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Specific options for {@linkcode downloadFile}. */
|
|
32
|
+
export interface DownloadOptions extends NetOptions {
|
|
33
|
+
/** Request timeout in milliseconds; defaults to {@linkcode DEFAULT_TIMEOUT_MS} */
|
|
34
|
+
timeout?: number;
|
|
35
|
+
/** Request headers mapping */
|
|
36
|
+
headers?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Options for {@linkcode uploadFile} when the remote uses the `http(s)` protocol. */
|
|
40
|
+
export interface HttpUploadOptions extends NetOptions {
|
|
41
|
+
/** Additional request headers */
|
|
42
|
+
headers?: HTTPHeaders;
|
|
43
|
+
/** HTTP method for file upload. Defaults to 'POST'. */
|
|
44
|
+
method?: Method;
|
|
45
|
+
/** Request timeout in milliseconds; defaults to {@linkcode DEFAULT_TIMEOUT_MS} */
|
|
46
|
+
timeout?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Name of the form field containing the file. Any falsy value uses non-multipart upload.
|
|
49
|
+
* Defaults to 'file'.
|
|
50
|
+
*/
|
|
51
|
+
fileFieldName?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Additional form fields. Only considered if `fileFieldName` is set.
|
|
54
|
+
*/
|
|
55
|
+
formFields?: Record<string, unknown> | [string, unknown][];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Options for {@linkcode uploadFile} when the remote uses the `ftp` protocol.
|
|
60
|
+
*/
|
|
61
|
+
export interface FtpUploadOptions extends NetOptions {}
|
|
62
|
+
|
|
63
|
+
/** @deprecated Use {@linkcode FtpUploadOptions} instead. */
|
|
64
|
+
export type NotHttpUploadOptions = FtpUploadOptions;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Uploads the given file to a remote location. HTTP(S) and FTP protocols are supported.
|
|
68
|
+
*/
|
|
69
|
+
export async function uploadFile(
|
|
70
|
+
localPath: string,
|
|
71
|
+
remoteUri: string,
|
|
72
|
+
uploadOptions: HttpUploadOptions | FtpUploadOptions = {}
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (!(await fs.exists(localPath))) {
|
|
75
|
+
throw new Error(`'${localPath}' does not exist or is not accessible`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const {isMetered = true} = uploadOptions;
|
|
79
|
+
const url = new URL(remoteUri);
|
|
80
|
+
const {size} = await fs.stat(localPath);
|
|
81
|
+
if (isMetered) {
|
|
82
|
+
log.info(`Uploading '${localPath}' of ${toReadableSizeString(size)} size to '${remoteUri}'`);
|
|
83
|
+
}
|
|
84
|
+
const timer = new Timer().start();
|
|
85
|
+
if (isHttpUploadOptions(uploadOptions, url)) {
|
|
86
|
+
if (!uploadOptions.fileFieldName) {
|
|
87
|
+
uploadOptions.headers = {
|
|
88
|
+
...(_.isPlainObject(uploadOptions.headers) ? uploadOptions.headers : {}),
|
|
89
|
+
'Content-Length': size,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
await uploadFileToHttp(fs.createReadStream(localPath), url, uploadOptions);
|
|
93
|
+
} else if (isFtpUploadOptions(uploadOptions, url)) {
|
|
94
|
+
await uploadFileToFtp(fs.createReadStream(localPath), url, uploadOptions);
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Cannot upload the file at '${localPath}' to '${remoteUri}'. ` +
|
|
98
|
+
`Unsupported remote protocol '${url.protocol}'. ` +
|
|
99
|
+
`Only http/https and ftp/ftps protocols are supported.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (isMetered) {
|
|
103
|
+
log.info(
|
|
104
|
+
`Uploaded '${localPath}' of ${toReadableSizeString(size)} size in ` +
|
|
105
|
+
`${timer.getDuration().asSeconds.toFixed(3)}s`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Downloads the given file via HTTP(S).
|
|
112
|
+
*
|
|
113
|
+
* @throws {Error} If download operation fails
|
|
114
|
+
*/
|
|
115
|
+
export async function downloadFile(
|
|
116
|
+
remoteUrl: string,
|
|
117
|
+
dstPath: string,
|
|
118
|
+
downloadOptions: DownloadOptions = {}
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const {isMetered = true, auth, timeout = DEFAULT_TIMEOUT_MS, headers} = downloadOptions;
|
|
121
|
+
|
|
122
|
+
const requestOpts: RawAxiosRequestConfig = {
|
|
123
|
+
url: remoteUrl,
|
|
124
|
+
responseType: 'stream',
|
|
125
|
+
timeout,
|
|
126
|
+
};
|
|
127
|
+
const axiosAuth = toAxiosAuth(auth);
|
|
128
|
+
if (axiosAuth) {
|
|
129
|
+
requestOpts.auth = axiosAuth;
|
|
130
|
+
}
|
|
131
|
+
if (_.isPlainObject(headers)) {
|
|
132
|
+
requestOpts.headers = headers as RawAxiosRequestConfig['headers'];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const timer = new Timer().start();
|
|
136
|
+
let responseLength: number;
|
|
137
|
+
try {
|
|
138
|
+
const writer = fs.createWriteStream(dstPath);
|
|
139
|
+
const {data: responseStream, headers: responseHeaders} = await axios(requestOpts);
|
|
140
|
+
responseLength = parseInt(String(responseHeaders['content-length'] ?? '0'), 10);
|
|
141
|
+
(responseStream as NodeJS.ReadableStream).pipe(writer);
|
|
142
|
+
|
|
143
|
+
await new Promise<void>((resolve, reject) => {
|
|
144
|
+
(responseStream as NodeJS.ReadableStream).once('error', reject);
|
|
145
|
+
writer.once('finish', () => resolve());
|
|
146
|
+
writer.once('error', (e: Error) => {
|
|
147
|
+
(responseStream as NodeJS.ReadableStream).unpipe(writer);
|
|
148
|
+
reject(e);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
153
|
+
throw new Error(`Cannot download the file from ${remoteUrl}: ${message}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const {size} = await fs.stat(dstPath);
|
|
157
|
+
if (responseLength && size !== responseLength) {
|
|
158
|
+
await fs.rimraf(dstPath);
|
|
159
|
+
throw new Error(
|
|
160
|
+
`The size of the file downloaded from ${remoteUrl} (${size} bytes) ` +
|
|
161
|
+
`differs from the one in Content-Length response header (${responseLength} bytes)`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (isMetered) {
|
|
165
|
+
const secondsElapsed = timer.getDuration().asSeconds;
|
|
166
|
+
log.debug(
|
|
167
|
+
`${remoteUrl} (${toReadableSizeString(size)}) ` +
|
|
168
|
+
`has been downloaded to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`
|
|
169
|
+
);
|
|
170
|
+
if (secondsElapsed >= 2) {
|
|
171
|
+
const bytesPerSec = Math.floor(size / secondsElapsed);
|
|
172
|
+
log.debug(`Approximate download speed: ${toReadableSizeString(bytesPerSec)}/s`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// #region Private helpers
|
|
178
|
+
|
|
179
|
+
type AuthLike = AuthCredentials | AxiosBasicCredentials;
|
|
180
|
+
|
|
181
|
+
function toAxiosAuth(auth: AuthLike | undefined): AxiosBasicCredentials | null {
|
|
182
|
+
if (!auth || !_.isPlainObject(auth)) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const username = 'username' in auth ? auth.username : auth.user;
|
|
187
|
+
const password = 'password' in auth ? auth.password : auth.pass;
|
|
188
|
+
return username && password ? {username, password} : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function uploadFileToHttp(
|
|
192
|
+
localFileStream: NodeJS.ReadableStream,
|
|
193
|
+
parsedUri: URL,
|
|
194
|
+
uploadOptions: HttpUploadOptions = {}
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const {
|
|
197
|
+
method = 'POST',
|
|
198
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
199
|
+
headers,
|
|
200
|
+
auth,
|
|
201
|
+
fileFieldName = 'file',
|
|
202
|
+
formFields,
|
|
203
|
+
} = uploadOptions;
|
|
204
|
+
const {href} = parsedUri;
|
|
205
|
+
|
|
206
|
+
const requestOpts: RawAxiosRequestConfig = {
|
|
207
|
+
url: href,
|
|
208
|
+
method,
|
|
209
|
+
timeout,
|
|
210
|
+
maxContentLength: Infinity,
|
|
211
|
+
maxBodyLength: Infinity,
|
|
212
|
+
};
|
|
213
|
+
const axiosAuth = toAxiosAuth(auth);
|
|
214
|
+
if (axiosAuth) {
|
|
215
|
+
requestOpts.auth = axiosAuth;
|
|
216
|
+
}
|
|
217
|
+
if (fileFieldName) {
|
|
218
|
+
const form = new FormData();
|
|
219
|
+
if (formFields) {
|
|
220
|
+
let pairs: [string, unknown][] = [];
|
|
221
|
+
if (_.isArray(formFields)) {
|
|
222
|
+
pairs = formFields as [string, unknown][];
|
|
223
|
+
} else if (_.isPlainObject(formFields)) {
|
|
224
|
+
pairs = _.toPairs(formFields);
|
|
225
|
+
}
|
|
226
|
+
for (const [key, value] of pairs) {
|
|
227
|
+
if (_.toLower(key) !== _.toLower(fileFieldName)) {
|
|
228
|
+
form.append(key, value as string | Buffer);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// AWS S3 POST upload requires this to be the last field; do not move before formFields.
|
|
233
|
+
form.append(fileFieldName, localFileStream);
|
|
234
|
+
requestOpts.headers = {
|
|
235
|
+
...(_.isPlainObject(headers) ? headers : {}),
|
|
236
|
+
...form.getHeaders(),
|
|
237
|
+
};
|
|
238
|
+
requestOpts.data = form;
|
|
239
|
+
} else {
|
|
240
|
+
if (_.isPlainObject(headers)) {
|
|
241
|
+
requestOpts.headers = headers;
|
|
242
|
+
}
|
|
243
|
+
requestOpts.data = localFileStream;
|
|
244
|
+
}
|
|
245
|
+
log.debug(
|
|
246
|
+
`Performing ${method} to ${href} with options (excluding data): ` +
|
|
247
|
+
JSON.stringify(_.omit(requestOpts, ['data']))
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const {status, statusText} = await axios(requestOpts);
|
|
251
|
+
log.info(`Server response: ${status} ${statusText}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function uploadFileToFtp(
|
|
255
|
+
localFileStream: string | Buffer | NodeJS.ReadableStream,
|
|
256
|
+
parsedUri: URL,
|
|
257
|
+
uploadOptions: FtpUploadOptions = {}
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
const {auth} = uploadOptions;
|
|
260
|
+
const {protocol, hostname, port, pathname} = parsedUri;
|
|
261
|
+
|
|
262
|
+
const ftpOpts: {host: string; port: number; user?: string; pass?: string} = {
|
|
263
|
+
host: hostname ?? '',
|
|
264
|
+
port: port !== undefined && port !== '' ? _.parseInt(port, 10) : 21,
|
|
265
|
+
};
|
|
266
|
+
if (auth?.user && auth?.pass) {
|
|
267
|
+
ftpOpts.user = auth.user;
|
|
268
|
+
ftpOpts.pass = auth.pass;
|
|
269
|
+
}
|
|
270
|
+
log.debug(`${protocol.slice(0, -1)} upload options: ${JSON.stringify(ftpOpts)}`);
|
|
271
|
+
return await new Promise<void>((resolve, reject) => {
|
|
272
|
+
new Ftp(ftpOpts).put(localFileStream, pathname, (err) => {
|
|
273
|
+
if (err) {
|
|
274
|
+
reject(err);
|
|
275
|
+
} else {
|
|
276
|
+
resolve();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isHttpUploadOptions(
|
|
283
|
+
opts: HttpUploadOptions | FtpUploadOptions,
|
|
284
|
+
url: URL
|
|
285
|
+
): opts is HttpUploadOptions {
|
|
286
|
+
try {
|
|
287
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Returns true if the URL is FTP, i.e. the options are for FTP upload. */
|
|
294
|
+
function isFtpUploadOptions(
|
|
295
|
+
opts: HttpUploadOptions | FtpUploadOptions,
|
|
296
|
+
url: URL
|
|
297
|
+
): opts is FtpUploadOptions {
|
|
298
|
+
try {
|
|
299
|
+
return url.protocol === 'ftp:';
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// #endregion
|