tr-fetch 0.9.0

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.
@@ -0,0 +1,633 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const tls = require('node:tls');
6
+ const tty = require('node:tty');
7
+ const { Agent } = require('undici');
8
+ const trFetch = require('..');
9
+ const { parseArguments, helpText } = require('./options');
10
+ const Progress = require('./progress');
11
+ const { version } = require('../package.json');
12
+
13
+ const REDIRECTS = [ 301, 302, 303, 307, 308 ];
14
+ // Undici owns these; fetch would ignore or reject caller-supplied values.
15
+ const FETCH_CONTROLLED = [ 'host', 'connection', 'content-length', 'keep-alive', 'transfer-encoding', 'upgrade', 'expect' ];
16
+ const CERTIFICATE_ERROR = /^(CERT_|UNABLE_TO_|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|INVALID_CA|INVALID_PURPOSE|PATH_LENGTH_EXCEEDED|HOSTNAME_MISMATCH|ERR_TLS_CERT_ALTNAME_INVALID)/;
17
+
18
+ class CurlError extends Error {
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.code = code;
22
+ }
23
+ }
24
+
25
+ function report(config, code, message) {
26
+ if ((message !== undefined) && (! config.silent || config.showError)) {
27
+ process.stderr.write(`tr-curl: (${code}) ${message}\n`);
28
+ }
29
+ }
30
+
31
+ function verbose(config, prefix, lines) {
32
+ if (config.verbose) {
33
+ process.stderr.write(lines.map(line => `${prefix} ${line}`.trimEnd() + '\n').join(''));
34
+ }
35
+ }
36
+
37
+ function writeStdout(chunk) {
38
+ return new Promise(function(resolve, reject) {
39
+ process.stdout.write(chunk, error => error ? reject(error) : resolve());
40
+ });
41
+ }
42
+
43
+ async function readInput(name, cache) {
44
+ if (name === '-') {
45
+ if (cache.stdin === undefined) {
46
+ const chunks = [];
47
+ for await (const chunk of process.stdin) {
48
+ chunks.push(chunk);
49
+ }
50
+ cache.stdin = Buffer.concat(chunks);
51
+ }
52
+ return cache.stdin;
53
+ }
54
+ try {
55
+ return await fs.promises.readFile(name);
56
+ } catch (error) {
57
+ throw new CurlError(26, `Failed to open ${name}: ${error.message}`);
58
+ }
59
+ }
60
+
61
+ function urlencode(value) {
62
+ return encodeURIComponent(value).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
63
+ }
64
+
65
+ async function requestBody(config, cache) {
66
+ let body;
67
+ for (const { kind, value } of config.data) {
68
+ let piece;
69
+ if ((kind === 'raw') || ! value.startsWith('@') || (kind === 'urlencode')) {
70
+ piece = Buffer.from(value);
71
+ } else {
72
+ piece = await readInput(value.slice(1), cache);
73
+ if (kind === 'data') {
74
+ piece = Buffer.from(piece.filter(x => (x !== 0x0d) && (x !== 0x0a)));
75
+ }
76
+ }
77
+ if (kind === 'urlencode') {
78
+ const match = value.match(/^([^=@]*)([=@])([^]*)$/);
79
+ if (! match) {
80
+ piece = Buffer.from(urlencode(value));
81
+ } else {
82
+ const content = (match[2] === '@') ? (await readInput(match[3], cache)).toString() : match[3];
83
+ piece = Buffer.from((match[1] ? `${match[1]}=` : '') + urlencode(content));
84
+ }
85
+ }
86
+ const separator = ((body !== undefined) && (kind !== 'json')) ? Buffer.from('&') : Buffer.alloc(0);
87
+ body = Buffer.concat([ body ?? Buffer.alloc(0), separator, piece ]);
88
+ }
89
+ return body;
90
+ }
91
+
92
+ async function customHeaders(config, cache) {
93
+ const lines = [];
94
+ for (const value of config.headers) {
95
+ if (value.startsWith('@')) {
96
+ lines.push(...(await readInput(value.slice(1), cache)).toString().split(/\r?\n/).filter(x => x.trim()));
97
+ } else {
98
+ lines.push(value);
99
+ }
100
+ }
101
+ const headers = [];
102
+ for (const line of lines) {
103
+ // "Name:" removes an internal header and "Name;" sends an empty one.
104
+ const match = line.match(/^([^:;\s]+)\s*(?::\s*([^]*?)\s*|;\s*)$/);
105
+ if (! match) {
106
+ process.stderr.write(`Warning: ignoring malformed header ${JSON.stringify(line)}\n`);
107
+ continue;
108
+ }
109
+ const value = (match[2] === undefined) ? '' : match[2];
110
+ if (FETCH_CONTROLLED.includes(match[1].toLowerCase())) {
111
+ process.stderr.write(`Warning: fetch controls the ${match[1]} header; ignoring it\n`);
112
+ continue;
113
+ }
114
+ headers.push({ name: match[1], value: (line.includes(':') && (value === '')) ? null : value });
115
+ }
116
+ return headers;
117
+ }
118
+
119
+ function promptPassword(user) {
120
+ return new Promise(function(resolve) {
121
+ let input;
122
+ try {
123
+ input = new tty.ReadStream(fs.openSync('/dev/tty', 'r'));
124
+ input.setRawMode(true);
125
+ } catch (_) {
126
+ input?.destroy();
127
+ resolve('');
128
+ return;
129
+ }
130
+ process.stderr.write(`Enter host password for user '${user}':`);
131
+ let password = '';
132
+ input.setEncoding('utf8');
133
+ input.on('data', function(text) {
134
+ for (const c of text) {
135
+ if ((c === '\r') || (c === '\n') || (c === '\u0004')) {
136
+ input.setRawMode(false);
137
+ input.destroy();
138
+ process.stderr.write('\n');
139
+ resolve(password);
140
+ return;
141
+ } else if (c === '\u0003') {
142
+ input.setRawMode(false);
143
+ process.stderr.write('\n');
144
+ process.exit(130);
145
+ } else if ((c === '\u007f') || (c === '\b')) {
146
+ password = password.slice(0, -1);
147
+ } else {
148
+ password += c;
149
+ }
150
+ }
151
+ });
152
+ });
153
+ }
154
+
155
+ function readFileOrFail(file, code, message) {
156
+ try {
157
+ return fs.readFileSync(file);
158
+ } catch (error) {
159
+ throw new CurlError(code, `${message} ${file}: ${error.message}`);
160
+ }
161
+ }
162
+
163
+ // TLS settings are process wide, which is fine for a command line tool, and
164
+ // apply to trFetch connections without weakening its verification.
165
+ function setupTls(config) {
166
+ if (config.tlsMax !== undefined) {
167
+ tls.DEFAULT_MAX_VERSION = config.tlsMax;
168
+ if (config.tlsMin === undefined) {
169
+ tls.DEFAULT_MIN_VERSION = [ tls.DEFAULT_MIN_VERSION, config.tlsMax ].sort()[0];
170
+ }
171
+ }
172
+ if (config.tlsMin !== undefined) {
173
+ tls.DEFAULT_MIN_VERSION = config.tlsMin;
174
+ }
175
+ if ((config.ciphers !== undefined) || (config.tls13Ciphers !== undefined)) {
176
+ const current = tls.DEFAULT_CIPHERS.split(':');
177
+ const tls13 = config.tls13Ciphers ?? current.filter(x => x.startsWith('TLS_')).join(':');
178
+ const tls12 = config.ciphers ?? current.filter(x => ! x.startsWith('TLS_')).join(':');
179
+ const ciphers = [ tls13, tls12 ].filter(Boolean).join(':');
180
+ if (tls13.split(':').some(x => ! x.startsWith('TLS_')) || tls12.split(':').some(x => x.startsWith('TLS_'))) {
181
+ throw new CurlError(59, `failed setting cipher list: ${ciphers}`);
182
+ }
183
+ try {
184
+ tls.createSecureContext({ ciphers });
185
+ } catch (_) {
186
+ throw new CurlError(59, `failed setting cipher list: ${ciphers}`);
187
+ }
188
+ tls.DEFAULT_CIPHERS = ciphers;
189
+ }
190
+ if (config.cacert !== undefined) {
191
+ const certificates = readFileOrFail(config.cacert, 77, 'error setting certificate file')
192
+ .toString().match(/-----BEGIN CERTIFICATE-----[^]+?-----END CERTIFICATE-----/g);
193
+ if (! certificates) {
194
+ throw new CurlError(77, `error setting certificate file ${config.cacert}: no PEM certificates`);
195
+ }
196
+ try {
197
+ tls.setDefaultCACertificates(certificates);
198
+ } catch (error) {
199
+ throw new CurlError(77, `error setting certificate file ${config.cacert}: ${error.message}`);
200
+ }
201
+ }
202
+ }
203
+
204
+ async function prepare(config) {
205
+ const cache = {};
206
+ setupTls(config);
207
+ const trFetchOptions = { ...config.trFetchOptions };
208
+ if (config.crlFile !== undefined) {
209
+ trFetchOptions.trFetchCrlOverride = readFileOrFail(config.crlFile, 82, 'error loading CRL file');
210
+ }
211
+ let user;
212
+ if (config.user !== undefined) {
213
+ const separator = config.user.indexOf(':');
214
+ user = (separator < 0) ? { name: config.user, password: await promptPassword(config.user) } :
215
+ { name: config.user.slice(0, separator), password: config.user.slice(separator + 1) };
216
+ }
217
+ let etag;
218
+ if (config.etagCompare !== undefined) {
219
+ try {
220
+ etag = fs.readFileSync(config.etagCompare, 'utf8').split(/\r?\n/)[0].trim();
221
+ } catch (error) {
222
+ process.stderr.write(`Warning: Failed to open ${config.etagCompare}: ${error.message}\n`);
223
+ }
224
+ etag ||= '""';
225
+ }
226
+ let referer, refererAuto = false;
227
+ if (config.referer !== undefined) {
228
+ refererAuto = config.referer.endsWith(';auto');
229
+ referer = refererAuto ? config.referer.slice(0, -5) : config.referer;
230
+ }
231
+ return { headers: await customHeaders(config, cache), body: await requestBody(config, cache),
232
+ user, etag, referer, refererAuto, trFetchOptions };
233
+ }
234
+
235
+ function parseUrl(value, base) {
236
+ let url;
237
+ if (base === undefined) {
238
+ value = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`;
239
+ const scheme = value.slice(0, value.indexOf(':')).toLowerCase();
240
+ if (! [ 'http', 'https' ].includes(scheme)) {
241
+ throw new CurlError(1, `Protocol "${scheme}" not supported`);
242
+ }
243
+ }
244
+ try {
245
+ url = new URL(value, base);
246
+ } catch (_) {
247
+ throw new CurlError(3, 'URL rejected: Malformed input to a URL function');
248
+ }
249
+ if (! [ 'http:', 'https:' ].includes(url.protocol)) {
250
+ throw new CurlError(1, `Protocol "${url.protocol.slice(0, -1)}" not supported`);
251
+ }
252
+ return url;
253
+ }
254
+
255
+ function outputFile(config, index, url) {
256
+ let name;
257
+ if (index < config.outputs.length) {
258
+ name = config.outputs[index];
259
+ if (name === '-') {
260
+ return { explicit: true };
261
+ }
262
+ } else if (config.remoteNameAll || (index < config.remoteName)) {
263
+ name = url.pathname.slice(url.pathname.lastIndexOf('/') + 1);
264
+ if (! name) {
265
+ throw new CurlError(23, 'Remote file name has no length');
266
+ }
267
+ } else {
268
+ return { explicit: false };
269
+ }
270
+ if ((config.outputDir !== undefined) && ! path.isAbsolute(name)) {
271
+ name = path.join(config.outputDir, name);
272
+ }
273
+ return { file: name };
274
+ }
275
+
276
+ class Output {
277
+ #config;
278
+ #file;
279
+ #handle;
280
+ created = false;
281
+
282
+ constructor(config, file) {
283
+ this.#config = config;
284
+ this.#file = file;
285
+ }
286
+
287
+ get isFile() {
288
+ return this.#file !== undefined;
289
+ }
290
+
291
+ async open() {
292
+ if ((this.#file === undefined) || this.#handle) {
293
+ return;
294
+ }
295
+ try {
296
+ if (this.#config.createDirs) {
297
+ await fs.promises.mkdir(path.dirname(this.#file), { recursive: true });
298
+ }
299
+ this.#handle = await fs.promises.open(this.#file, 'w');
300
+ this.created = true;
301
+ } catch (error) {
302
+ throw new CurlError(23, `Failed to open the file ${this.#file}: ${error.message}`);
303
+ }
304
+ }
305
+
306
+ async write(chunk) {
307
+ try {
308
+ if (this.#file === undefined) {
309
+ await writeStdout(chunk);
310
+ } else {
311
+ await this.open();
312
+ await this.#handle.write(chunk);
313
+ }
314
+ } catch (error) {
315
+ throw (error instanceof CurlError) ? error : new CurlError(23, `Failure writing output to destination: ${error.message}`);
316
+ }
317
+ }
318
+
319
+ async close() {
320
+ await this.#handle?.close();
321
+ this.#handle = undefined;
322
+ }
323
+
324
+ async remove() {
325
+ await this.close();
326
+ if (this.created) {
327
+ await fs.promises.rm(this.#file, { force: true });
328
+ }
329
+ }
330
+ }
331
+
332
+ function failure(error, url, gotResponse) {
333
+ if (error instanceof CurlError) {
334
+ return error;
335
+ }
336
+ if (error instanceof trFetch.TrFetchCrlError) {
337
+ return new CurlError(60, error.message);
338
+ }
339
+ if (error instanceof trFetch.TrFetchOcspError) {
340
+ return new CurlError(91, error.message);
341
+ }
342
+ const causes = [];
343
+ for (let cause = error; cause && (causes.length < 10); cause = cause.cause) {
344
+ causes.push(cause);
345
+ }
346
+ const cause = causes.find(x => typeof(x.code) === 'string') ?? causes.at(-1);
347
+ const code = cause?.code ?? '';
348
+ const message = cause?.message ?? String(error);
349
+ url ??= new URL('http://unknown/');
350
+ const port = url.port || ((url.protocol === 'https:') ? 443 : 80);
351
+ if (/^(ENOTFOUND|EAI_)/.test(code)) {
352
+ return new CurlError(6, `Could not resolve host: ${url.hostname}`);
353
+ }
354
+ if ([ 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'EADDRNOTAVAIL', 'ETIMEDOUT' ].includes(code) || (message === 'bad port')) {
355
+ return new CurlError(7, `Failed to connect to ${url.hostname} port ${port}: ${message}`);
356
+ }
357
+ if ([ 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT' ].includes(code)) {
358
+ return new CurlError(28, message);
359
+ }
360
+ if (CERTIFICATE_ERROR.test(code) || message.startsWith('TLS certificate verification failed')) {
361
+ return new CurlError(60, `SSL certificate problem: ${message}`);
362
+ }
363
+ if (/^ERR_(SSL|TLS)_/.test(code) || (code === 'EPROTO')) {
364
+ return new CurlError(35, `TLS connect error: ${message}`);
365
+ }
366
+ if (code.startsWith('HPE_') || (cause?.name === 'HTTPParserError')) {
367
+ return new CurlError(8, `Weird server reply: ${message}`);
368
+ }
369
+ if ((code === 'UND_ERR_SOCKET') && ! gotResponse) {
370
+ return new CurlError(52, 'Empty reply from server');
371
+ }
372
+ if ((error instanceof TypeError) && (error.message !== 'fetch failed') && (error.message !== 'terminated')) {
373
+ // Request construction or trFetch option errors.
374
+ return new CurlError(2, error.message);
375
+ }
376
+ return new CurlError(56, `Failure when receiving data from the peer: ${message}`);
377
+ }
378
+
379
+ function requestHeaders(config, prep, state) {
380
+ const internal = [];
381
+ const crossOrigin = (state.url.origin !== state.authOrigin) && ! config.locationTrusted;
382
+ internal.push([ 'User-Agent', config.userAgent ?? `tr-curl/${version}` ]);
383
+ internal.push([ 'Accept', config.json ? 'application/json' : '*/*' ]);
384
+ if ((state.authorization !== undefined) && ! crossOrigin) {
385
+ internal.push([ 'Authorization', state.authorization ]);
386
+ }
387
+ if (state.referer) {
388
+ internal.push([ 'Referer', state.referer ]);
389
+ }
390
+ if (config.range !== undefined) {
391
+ internal.push([ 'Range', `bytes=${config.range}` ]);
392
+ }
393
+ if (prep.etag !== undefined) {
394
+ internal.push([ 'If-None-Match', prep.etag ]);
395
+ }
396
+ if (state.body !== undefined) {
397
+ internal.push([ 'Content-Type', config.json ? 'application/json' : 'application/x-www-form-urlencoded' ]);
398
+ }
399
+ // Custom headers replace internal ones, and credentials stay on the
400
+ // original origin like curl unless --location-trusted is given.
401
+ const custom = prep.headers.filter(x => ! (crossOrigin && [ 'authorization', 'cookie' ].includes(x.name.toLowerCase())));
402
+ const replaced = new Set(custom.map(x => x.name.toLowerCase()));
403
+ return [ ...internal.filter(([name]) => ! replaced.has(name.toLowerCase())),
404
+ ...custom.filter(x => x.value !== null).map(x => [ x.name, x.value ]) ];
405
+ }
406
+
407
+ function headerBlock(response) {
408
+ const lines = [ `HTTP/1.1 ${response.status} ${response.statusText}`.trimEnd() ];
409
+ for (const [name, value] of response.headers) {
410
+ lines.push(`${name}: ${value}`);
411
+ }
412
+ return lines;
413
+ }
414
+
415
+ async function send(config, prep, url, init) {
416
+ if (! config.insecure) {
417
+ return trFetch(url, { trFetchDebug: config.verbose, ...prep.trFetchOptions, ...init });
418
+ }
419
+ const agent = new Agent({ allowH2: false, connect: { rejectUnauthorized: false } });
420
+ try {
421
+ const response = await globalThis.fetch(url, { ...init, dispatcher: agent });
422
+ void agent.close().catch(() => {});
423
+ return response;
424
+ } catch (error) {
425
+ await agent.destroy();
426
+ throw error;
427
+ }
428
+ }
429
+
430
+ async function exchange(config, prep, rawUrl, index, context) {
431
+ const initial = parseUrl(rawUrl);
432
+ const target = outputFile(config, index, initial);
433
+ const output = context.output = new Output(config, target.file);
434
+ const state = context.state = { url: new URL(initial), authOrigin: initial.origin, referer: prep.referer, body: prep.body };
435
+ const signal = context.signal;
436
+ if (initial.username || initial.password) {
437
+ state.authorization = 'Basic ' + Buffer.from(`${decodeURIComponent(initial.username)}:${decodeURIComponent(initial.password)}`).toString('base64');
438
+ state.url.username = '';
439
+ state.url.password = '';
440
+ }
441
+ if (prep.user !== undefined) {
442
+ state.authorization = 'Basic ' + Buffer.from(`${prep.user.name}:${prep.user.password}`).toString('base64');
443
+ }
444
+ if (config.bearer !== undefined) {
445
+ state.authorization = `Bearer ${config.bearer}`;
446
+ }
447
+ if (config.get && (state.body !== undefined)) {
448
+ state.url.search = state.url.search ? `${state.url.search}&${state.body}` : `?${state.body}`;
449
+ state.body = undefined;
450
+ }
451
+ let method = config.request ?? (config.head ? 'HEAD' : ((state.body !== undefined) ? 'POST' : 'GET'));
452
+ const showHeaders = config.include || config.head;
453
+ if (config.insecure) {
454
+ verbose(config, '*', [ 'WARNING: --insecure disables TLS verification and trFetch CRL/OCSP revocation checks' ]);
455
+ }
456
+ let response;
457
+ for (let redirects = 0; ; redirects++) {
458
+ const headers = requestHeaders(config, prep, state);
459
+ verbose(config, '*', [ `Fetching ${state.url.origin} with ${config.insecure ? 'plain fetch' : 'trFetch'}` ]);
460
+ verbose(config, '>', [ `${method} ${state.url.pathname}${state.url.search} HTTP/1.1`, `Host: ${state.url.host}`,
461
+ ...headers.map(([name, value]) => `${name}: ${value}`), '' ]);
462
+ verbose(config, '*', [ 'fetch adds headers of its own, such as Connection, Accept-Language, Sec-Fetch-Mode and Accept-Encoding' ]);
463
+ response = await send(config, prep, state.url, { method, headers, body: state.body, redirect: 'manual', signal });
464
+ context.gotResponse = true;
465
+ context.progress?.upload(state.body?.length ?? 0);
466
+ const lines = headerBlock(response);
467
+ verbose(config, '<', [ ...lines, '' ]);
468
+ const location = response.headers.get('location');
469
+ const follow = config.location && REDIRECTS.includes(response.status) && (location !== null);
470
+ if (! follow && (config.fail || config.failWithBody) && (response.status >= 400)) {
471
+ if (config.fail) {
472
+ await response.body?.cancel();
473
+ throw new CurlError(22, `The requested URL returned error: ${response.status}`);
474
+ }
475
+ context.failed = new CurlError(22, `The requested URL returned error: ${response.status}`);
476
+ }
477
+ if (showHeaders) {
478
+ await output.write(lines.join('\r\n') + '\r\n\r\n');
479
+ }
480
+ if (! follow) {
481
+ break;
482
+ }
483
+ await response.body?.cancel();
484
+ if ((config.maxRedirs !== -1) && (redirects >= config.maxRedirs)) {
485
+ throw new CurlError(47, `Maximum (${config.maxRedirs}) redirects followed`);
486
+ }
487
+ const next = parseUrl(location, state.url);
488
+ next.hash = '';
489
+ verbose(config, '*', [ `Issue another request to this URL: '${next.href}'` ]);
490
+ if ((response.status === 303) ? (method !== 'HEAD') : ((response.status <= 302) && (method === 'POST'))) {
491
+ // Browsers and curl switch to GET here; -X keeps its method string.
492
+ method = config.request ?? 'GET';
493
+ state.body = undefined;
494
+ }
495
+ if (prep.refererAuto) {
496
+ const previous = new URL(state.url);
497
+ previous.hash = '';
498
+ state.referer = previous.href;
499
+ }
500
+ state.url = next;
501
+ }
502
+ if (config.head || (method === 'HEAD') || ! response.body) {
503
+ await response.body?.cancel();
504
+ return;
505
+ }
506
+ const encoded = response.headers.has('content-encoding');
507
+ const length = encoded ? NaN : Number(response.headers.get('content-length') ?? NaN);
508
+ const total = Number.isSafeInteger(length) ? length : undefined;
509
+ if ((config.maxFilesize !== undefined) && (total > config.maxFilesize)) {
510
+ await response.body.cancel();
511
+ throw new CurlError(63, 'Maximum file size exceeded');
512
+ }
513
+ context.progress?.expect(total);
514
+ const checkBinary = ! output.isFile && ! target.explicit && process.stdout.isTTY;
515
+ const iterator = response.body[Symbol.asyncIterator]();
516
+ for (;;) {
517
+ let step;
518
+ try {
519
+ step = await iterator.next();
520
+ } catch (error) {
521
+ if (signal?.aborted || (error instanceof CurlError)) {
522
+ throw error;
523
+ }
524
+ throw (total !== undefined) ?
525
+ new CurlError(18, `transfer closed with ${total - context.received} bytes remaining to read`) :
526
+ failure(error, state.url, true);
527
+ }
528
+ if (step.done) {
529
+ break;
530
+ }
531
+ const chunk = step.value;
532
+ if (checkBinary && (context.received === 0) && chunk.includes(0)) {
533
+ await iterator.return();
534
+ process.stderr.write('Warning: Binary output can mess up your terminal. Use "--output -" to tell\n' +
535
+ 'Warning: tr-curl to output it to your terminal anyway, or consider "--output\n' +
536
+ 'Warning: <FILE>" to save to a file.\n');
537
+ throw new CurlError(23);
538
+ }
539
+ context.received += chunk.length;
540
+ if ((config.maxFilesize !== undefined) && (context.received > config.maxFilesize)) {
541
+ await iterator.return();
542
+ throw new CurlError(63, 'Maximum file size exceeded');
543
+ }
544
+ await output.write(chunk);
545
+ context.progress?.update(context.received);
546
+ }
547
+ }
548
+
549
+ async function transfer(config, prep, rawUrl, index) {
550
+ const started = Date.now();
551
+ const timeout = (config.maxTime === undefined) ? undefined : Math.max(1, Math.round(config.maxTime * 1000));
552
+ const context = { received: 0, gotResponse: false, state: undefined,
553
+ signal: (timeout === undefined) ? undefined : AbortSignal.timeout(timeout) };
554
+ const toStdout = (index >= config.outputs.length) && ! config.remoteNameAll && (index >= config.remoteName) ||
555
+ (config.outputs[index] === '-');
556
+ if (! config.silent && (config.progressBar || (config.progressMeter && ! (toStdout && process.stdout.isTTY)))) {
557
+ context.progress = new Progress(config.progressBar ? 'bar' : 'meter');
558
+ }
559
+ let error;
560
+ try {
561
+ await exchange(config, prep, rawUrl, index, context);
562
+ error = context.failed;
563
+ } catch (caught) {
564
+ error = (context.signal?.aborted && ! (caught instanceof CurlError)) ?
565
+ new CurlError(28, `Operation timed out after ${Date.now() - started} milliseconds with ${context.received} bytes received`) :
566
+ failure(caught, context.state?.url, context.gotResponse);
567
+ }
568
+ context.progress?.finish();
569
+ try {
570
+ if (error && config.removeOnError) {
571
+ await context.output?.remove();
572
+ } else if (! error || (error === context.failed)) {
573
+ // Like curl, a successful transfer creates the file even when empty.
574
+ await context.output?.open();
575
+ }
576
+ await context.output?.close();
577
+ } catch (caught) {
578
+ error ??= failure(caught, context.state?.url);
579
+ }
580
+ if (error) {
581
+ report(config, error.code, error.message);
582
+ return error.code;
583
+ }
584
+ verbose(config, '*', [ `Transfer complete: ${context.received} bytes received in ${(Date.now() - started) / 1000} s` ]);
585
+ return 0;
586
+ }
587
+
588
+ async function main(argv) {
589
+ let config;
590
+ try {
591
+ config = parseArguments(argv);
592
+ } catch (error) {
593
+ process.stderr.write(`tr-curl: ${error.message}\ntr-curl: try 'tr-curl --help' for more information\n`);
594
+ return 2;
595
+ }
596
+ if (config.help) {
597
+ process.stdout.write(helpText());
598
+ return 0;
599
+ }
600
+ if (config.version) {
601
+ process.stdout.write(`tr-curl ${version} (tr-fetch ${version}) Node.js/${process.version} ` +
602
+ `undici/${process.versions.undici} OpenSSL/${process.versions.openssl}\n` +
603
+ 'Protocols: http https\nFeatures: Basic-auth Bearer-auth CRL OCSP SSL\n');
604
+ return 0;
605
+ }
606
+ if (! config.urls.length) {
607
+ process.stderr.write('tr-curl: no URL specified\ntr-curl: try \'tr-curl --help\' for more information\n');
608
+ return 2;
609
+ }
610
+ // A closed pipe is reported through the write callback as exit code 23.
611
+ process.stdout.on('error', () => {});
612
+ let prep;
613
+ try {
614
+ prep = await prepare(config);
615
+ } catch (error) {
616
+ const failed = (error instanceof CurlError) ? error : new CurlError(2, error.message);
617
+ report(config, failed.code, failed.message);
618
+ return failed.code;
619
+ }
620
+ let result = 0;
621
+ for (let index = 0; index < config.urls.length; index++) {
622
+ const code = await transfer(config, prep, config.urls[index], index);
623
+ if (code) {
624
+ result = code;
625
+ if (config.failEarly) {
626
+ break;
627
+ }
628
+ }
629
+ }
630
+ return result;
631
+ }
632
+
633
+ module.exports = { main };