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,193 @@
1
+ 'use strict';
2
+
3
+ const Optist = require('optist');
4
+
5
+ const TLS_VERSIONS = { '1': 'TLSv1', '1.0': 'TLSv1', '1.1': 'TLSv1.1', '1.2': 'TLSv1.2', '1.3': 'TLSv1.3' };
6
+ const TLS_RANK = [ 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3' ];
7
+ const SIZE_UNITS = { '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3, t: 1024 ** 4, p: 1024 ** 5 };
8
+
9
+ class UsageError extends Error {}
10
+
11
+ function sizeCb(value) {
12
+ const match = value.match(/^(\d+)([kmgtp]?)$/i);
13
+ const bytes = match ? Number(match[1]) * SIZE_UNITS[match[2].toLowerCase()] : NaN;
14
+ return Number.isSafeInteger(bytes) ? bytes : undefined;
15
+ }
16
+
17
+ function secondsCb(value) {
18
+ return /^(\d+(\.\d*)?|\.\d+)$/.test(value) ? Number(value) : undefined;
19
+ }
20
+
21
+ function redirectsCb(value) {
22
+ return /^(-1|0|[1-9]\d{0,8})$/.test(value) ? Number(value) : undefined;
23
+ }
24
+
25
+ function tlsMaxCb(value) {
26
+ return (value === 'default') ? value : TLS_VERSIONS[value];
27
+ }
28
+
29
+ function jsonObjectCb(value) {
30
+ try {
31
+ const parsed = JSON.parse(value);
32
+ return (parsed && (typeof(parsed) === 'object') && ! Array.isArray(parsed)) ? parsed : undefined;
33
+ } catch (_) {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ // Request data options share one ordered list, since curl joins them in the
39
+ // order given regardless of which data option supplied each piece.
40
+ function dataOptions(arg, tag) {
41
+ return [
42
+ [ 'd', 'data', 'data', '<data> HTTP POST data; @file reads a file with CR and LF removed' ],
43
+ [ undefined, 'data-ascii', 'data', '<data> Same as --data' ],
44
+ [ undefined, 'data-binary', 'binary', '<data> HTTP POST data; @file reads a file as is' ],
45
+ [ undefined, 'data-raw', 'raw', '<data> HTTP POST data without special meaning for @' ],
46
+ [ undefined, 'data-urlencode', 'urlencode', '<data> HTTP POST data, URL-encoded' ],
47
+ [ undefined, 'json', 'json', '<data> HTTP POST JSON; @file reads a file as is' ]
48
+ ].map(([shortName, longName, kind, description]) => arg(shortName, longName, description, tag(kind), true));
49
+ }
50
+
51
+ function optionDefinitions() {
52
+ let seq = 0;
53
+ const tag = kind => value => ({ seq: ++seq, kind, value });
54
+ const flag = (shortName, longName, description, multi = false) => ({ shortName, longName, multi, description });
55
+ const arg = (shortName, longName, description, optArgCb, multi = false) => ({ shortName, longName, hasArg: true, multi, optArgCb,
56
+ argName: description.slice(0, description.indexOf(' ')), description: description.slice(description.indexOf(' ') + 1) });
57
+ return [
58
+ arg('A', 'user-agent', '<name> Send User-Agent <name> to server'),
59
+ arg(undefined, 'cacert', '<file> CA certificates (PEM) to use instead of the default trust store'),
60
+ arg(undefined, 'ciphers', '<list> TLS 1.2 and older cipher list (OpenSSL format)'),
61
+ flag(undefined, 'create-dirs', 'Create missing local output directories'),
62
+ arg(undefined, 'crlfile', '<file> Check the server certificate against this CRL (PEM or DER)'),
63
+ ...dataOptions(arg, tag),
64
+ arg('e', 'referer', '<URL> Referrer URL; append ";auto" to update it on redirects'),
65
+ arg(undefined, 'etag-compare', '<file> Send If-None-Match with the ETag read from <file>'),
66
+ flag('f', 'fail', 'Fail fast with no output on HTTP errors'),
67
+ flag(undefined, 'fail-early', 'Stop at the first failed transfer'),
68
+ flag(undefined, 'fail-with-body', 'Fail on HTTP errors but save the body'),
69
+ flag('G', 'get', 'Put the post data in the URL and use GET'),
70
+ arg('H', 'header', '<header/@file> Pass custom header(s) to server', undefined, true),
71
+ flag('h', 'help', 'Show help and exit'),
72
+ flag('I', 'head', 'Show document info only'),
73
+ flag('i', 'include', 'Include response headers in the output'),
74
+ flag('k', 'insecure', 'Skip TLS verification; also bypasses trFetch revocation checks'),
75
+ flag('L', 'location', 'Follow redirects'),
76
+ flag(undefined, 'location-trusted', 'Like --location, and send authentication to other hosts'),
77
+ arg(undefined, 'max-filesize', '<bytes> Maximum file size to download (suffixes k, M, G, T, P)', sizeCb),
78
+ arg(undefined, 'max-redirs', '<num> Maximum number of redirects allowed (default 50, -1 unlimited)', redirectsCb),
79
+ arg('m', 'max-time', '<seconds> Maximum time allowed for each transfer', secondsCb),
80
+ flag(undefined, 'no-progress-meter', 'Do not show the progress meter'),
81
+ arg(undefined, 'oauth2-bearer', '<token> OAuth 2 Bearer Token'),
82
+ arg('o', 'output', '<file> Write to file instead of stdout (one per URL)', undefined, true),
83
+ arg(undefined, 'output-dir', '<dir> Directory to save files in'),
84
+ flag('#', 'progress-bar', 'Display transfer progress as a bar'),
85
+ arg('r', 'range', '<range> Retrieve only the bytes within RANGE'),
86
+ flag('O', 'remote-name', 'Write output to a file named as the remote file (one per URL)', true),
87
+ flag(undefined, 'remote-name-all', 'Use the remote file name for all URLs'),
88
+ flag(undefined, 'remove-on-error', 'Remove output file on errors'),
89
+ arg('X', 'request', '<method> Specify request method to use'),
90
+ flag('s', 'silent', 'Silent mode'),
91
+ flag('S', 'show-error', 'Show error even when -s is used'),
92
+ flag('1', 'tlsv1', 'Use TLSv1.0 or greater'),
93
+ flag(undefined, 'tlsv1.0', 'Use TLSv1.0 or greater'),
94
+ flag(undefined, 'tlsv1.1', 'Use TLSv1.1 or greater'),
95
+ flag(undefined, 'tlsv1.2', 'Use TLSv1.2 or greater'),
96
+ flag(undefined, 'tlsv1.3', 'Use TLSv1.3 or greater'),
97
+ arg(undefined, 'tls-max', '<version> Maximum TLS version: 1.0, 1.1, 1.2, 1.3 or default', tlsMaxCb),
98
+ arg(undefined, 'tls13-ciphers', '<list> TLS 1.3 cipher suites to use'),
99
+ arg(undefined, 'trfetch-options', '<json> Extra trFetch options as a JSON object', jsonObjectCb, true),
100
+ arg('u', 'user', '<user:password> Server user and password (basic authentication)'),
101
+ arg(undefined, 'url', '<url> URL to work with', undefined, true),
102
+ flag('v', 'verbose', 'Make the operation more talkative, including trFetch debug output', true),
103
+ flag('V', 'version', 'Show version number and quit')
104
+ ];
105
+ }
106
+
107
+ function helpText() {
108
+ const lines = optionDefinitions().map(function(o) {
109
+ const names = [ o.shortName && `-${o.shortName}`, `--${o.longName}` ].filter(Boolean).join(', ');
110
+ return [ (o.shortName ? ' ' : ' ') + names + (o.hasArg ? ` ${o.argName}` : ''), o.description ];
111
+ });
112
+ const width = Math.max(...lines.map(x => x[0].length)) + 2;
113
+ return 'Usage: tr-curl [options...] <url>...\n' + lines.map(([left, text]) => left.padEnd(width) + text + '\n').join('');
114
+ }
115
+
116
+ function parseArguments(argv) {
117
+ const opt = (new Optist()).opts(optionDefinitions()).parsePosix(Array.from(argv));
118
+ const value = name => opt.value(name);
119
+ if (value('fail') && value('fail-with-body')) {
120
+ throw new UsageError('--fail and --fail-with-body cannot be used together');
121
+ }
122
+ // Optist cannot preserve the relative order of -o and -O.
123
+ if (value('output').length && value('remote-name')) {
124
+ throw new UsageError('--output and --remote-name cannot be used together');
125
+ }
126
+ const trFetchOptions = Object.assign({}, ...value('trfetch-options'));
127
+ for (const key of Object.keys(trFetchOptions)) {
128
+ if (! /^trFetch/.test(key)) {
129
+ throw new UsageError(`--trfetch-options accepts only trFetch options, not ${JSON.stringify(key)}`);
130
+ }
131
+ }
132
+ const data = [ 'data', 'data-ascii', 'data-binary', 'data-raw', 'data-urlencode', 'json' ]
133
+ .flatMap(value).sort((a, b) => a.seq - b.seq);
134
+ if (value('head') && data.length && ! value('get')) {
135
+ throw new UsageError('You can only select one HTTP request method! You asked for both POST ' +
136
+ '(using --data or --json) and HEAD (using -I/--head).');
137
+ }
138
+ const tlsMin = [ 'tlsv1.3', 'tlsv1.2', 'tlsv1.1', 'tlsv1.0', 'tlsv1' ].find(value);
139
+ let tlsMax = value('tls-max');
140
+ if (tlsMax === 'default') {
141
+ tlsMax = undefined;
142
+ }
143
+ if (tlsMin && tlsMax && (TLS_RANK.indexOf(TLS_VERSIONS[tlsMin.slice(4)]) > TLS_RANK.indexOf(tlsMax))) {
144
+ throw new UsageError(`--${tlsMin} conflicts with --tls-max ${tlsMax.slice(4)}`);
145
+ }
146
+ return {
147
+ help: value('help'),
148
+ version: value('version'),
149
+ urls: [ ...value('url'), ...opt.rest() ],
150
+ headers: value('header'),
151
+ data,
152
+ json: data.some(x => x.kind === 'json'),
153
+ get: value('get'),
154
+ head: value('head'),
155
+ include: value('include'),
156
+ request: value('request'),
157
+ userAgent: value('user-agent'),
158
+ user: value('user'),
159
+ bearer: value('oauth2-bearer'),
160
+ referer: value('referer'),
161
+ range: value('range'),
162
+ etagCompare: value('etag-compare'),
163
+ location: value('location') || value('location-trusted'),
164
+ locationTrusted: value('location-trusted'),
165
+ maxRedirs: value('max-redirs') ?? 50,
166
+ maxTime: value('max-time'),
167
+ maxFilesize: value('max-filesize') || undefined,
168
+ fail: value('fail'),
169
+ failWithBody: value('fail-with-body'),
170
+ failEarly: value('fail-early'),
171
+ outputs: value('output'),
172
+ outputDir: value('output-dir'),
173
+ createDirs: value('create-dirs'),
174
+ remoteName: value('remote-name'),
175
+ remoteNameAll: value('remote-name-all'),
176
+ removeOnError: value('remove-on-error'),
177
+ silent: value('silent'),
178
+ showError: value('show-error'),
179
+ progressMeter: ! value('no-progress-meter'),
180
+ progressBar: value('progress-bar'),
181
+ verbose: value('verbose') > 0,
182
+ insecure: value('insecure'),
183
+ cacert: value('cacert'),
184
+ crlFile: value('crlfile'),
185
+ ciphers: value('ciphers'),
186
+ tls13Ciphers: value('tls13-ciphers'),
187
+ tlsMin: tlsMin && TLS_VERSIONS[tlsMin.slice(4)],
188
+ tlsMax,
189
+ trFetchOptions
190
+ };
191
+ }
192
+
193
+ module.exports = { parseArguments, helpText, UsageError };
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ const HEADER = ' % Total % Received % Xferd Average Speed Time Time Time Current\n' +
4
+ ' Dload Upload Total Spent Left Speed\n';
5
+
6
+ const UNITS = [ [ 'k', 1024 ], [ 'M', 1024 ** 2 ], [ 'G', 1024 ** 3 ], [ 'T', 1024 ** 4 ], [ 'P', 1024 ** 5 ] ];
7
+
8
+ // A byte count in five columns, like curl's meter.
9
+ function size5(value) {
10
+ value = Math.max(0, Math.floor(value));
11
+ if (value < 100000) {
12
+ return String(value).padStart(5);
13
+ }
14
+ for (const [unit, scale] of UNITS) {
15
+ if (value < 100 * scale) {
16
+ const tenths = Math.floor(value / (scale / 10));
17
+ return `${Math.floor(tenths / 10)}.${tenths % 10}${unit}`.padStart(5);
18
+ }
19
+ if (value < 10000 * scale) {
20
+ return `${Math.floor(value / scale)}${unit}`.padStart(5);
21
+ }
22
+ }
23
+ return `${Math.floor(value / (1024 ** 5))}P`.padStart(5);
24
+ }
25
+
26
+ function time8(seconds) {
27
+ if (! Number.isFinite(seconds)) {
28
+ return '--:--:--';
29
+ }
30
+ seconds = Math.floor(seconds);
31
+ const hours = Math.floor(seconds / 3600);
32
+ if (hours >= 100) {
33
+ return `${Math.floor(hours / 24)}d ${String(hours % 24).padStart(2, '0')}h`.padStart(8);
34
+ }
35
+ return `${String(hours).padStart(2)}:${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
36
+ }
37
+
38
+ function percent(part, total) {
39
+ return String(total ? Math.floor((part * 100) / total) : 0).padStart(3);
40
+ }
41
+
42
+ class Progress {
43
+ #mode;
44
+ #stream;
45
+ #started = Date.now();
46
+ #timer;
47
+ #total;
48
+ #received = 0;
49
+ #uploaded = 0;
50
+ #samples = [];
51
+ #bounce = 0;
52
+ #finished = false;
53
+
54
+ constructor(mode, stream = process.stderr) {
55
+ this.#mode = mode;
56
+ this.#stream = stream;
57
+ if (mode === 'meter') {
58
+ this.#write(HEADER);
59
+ }
60
+ this.#timer = setInterval(() => this.#render(), (mode === 'meter') ? 1000 : 200);
61
+ this.#timer.unref();
62
+ }
63
+
64
+ expect(total) {
65
+ this.#total = total;
66
+ }
67
+
68
+ upload(bytes) {
69
+ this.#uploaded = bytes;
70
+ }
71
+
72
+ update(received) {
73
+ this.#received = received;
74
+ }
75
+
76
+ finish() {
77
+ if (this.#finished) {
78
+ return;
79
+ }
80
+ this.#finished = true;
81
+ clearInterval(this.#timer);
82
+ this.#render(true);
83
+ this.#write('\n');
84
+ }
85
+
86
+ #write(text) {
87
+ try {
88
+ this.#stream.write(text);
89
+ } catch (_) {
90
+ // The meter must not change the transfer outcome.
91
+ }
92
+ }
93
+
94
+ #render(final = false) {
95
+ const now = Date.now();
96
+ if (this.#mode === 'bar') {
97
+ this.#renderBar(final);
98
+ return;
99
+ }
100
+ const spent = (now - this.#started) / 1000;
101
+ this.#samples.push([ now, this.#received ]);
102
+ while ((this.#samples.length > 1) && ((now - this.#samples[0][0]) > 5000)) {
103
+ this.#samples.shift();
104
+ }
105
+ const [sampleTime, sampleBytes] = this.#samples[0];
106
+ const current = (now > sampleTime) ? ((this.#received - sampleBytes) * 1000) / (now - sampleTime) :
107
+ (spent ? this.#received / spent : 0);
108
+ const dlSpeed = spent ? this.#received / spent : 0;
109
+ const ulSpeed = spent ? this.#uploaded / spent : 0;
110
+ const total = (this.#total ?? this.#received) + this.#uploaded;
111
+ const done = this.#received + this.#uploaded;
112
+ const expected = (this.#total !== undefined) && (dlSpeed > 0) ? this.#total / dlSpeed : undefined;
113
+ const left = (expected === undefined) ? undefined : Math.max(0, expected - spent);
114
+ this.#write(`\r${percent(done, total)} ${size5(total)} ${percent(this.#received, this.#total)} ${size5(this.#received)} ` +
115
+ `${percent(this.#uploaded, this.#uploaded)} ${size5(this.#uploaded)} ${size5(dlSpeed)} ${size5(ulSpeed)} ` +
116
+ `${time8(expected)} ${time8(spent)} ${time8(left)} ${size5(final ? dlSpeed : current)}`);
117
+ }
118
+
119
+ #renderBar(final) {
120
+ const width = Math.max(20, (this.#stream.columns || Number(process.env.COLUMNS) || 80) - 1);
121
+ const length = width - 7;
122
+ if (this.#total) {
123
+ const fraction = Math.min(1, this.#received / this.#total);
124
+ const bar = '#'.repeat(Math.round(fraction * length)).padEnd(length);
125
+ this.#write(`\r${bar} ${(fraction * 100).toFixed(1).padStart(5)}%`);
126
+ } else if (final) {
127
+ this.#write(`\r${'#'.repeat(length).padEnd(width)}`);
128
+ } else {
129
+ const position = this.#bounce++ % ((length - 5) * 2);
130
+ const offset = (position < (length - 5)) ? position : ((length - 5) * 2) - position;
131
+ this.#write(`\r${(' '.repeat(offset) + '-=O=-').padEnd(width)}`);
132
+ }
133
+ }
134
+ }
135
+
136
+ module.exports = Progress;
package/transport.js ADDED
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const { isIP } = require('node:net');
4
+ const { Agent, buildConnector, getGlobalDispatcher } = require('undici');
5
+ const { version: undiciVersion } = require('undici/package.json');
6
+ const { debugUrl } = require('./debug');
7
+
8
+ // Origins each agent actually connected to, after TLS and revocation checks.
9
+ const verifiedOrigins = new WeakMap();
10
+
11
+ // Do not silently discard a global dispatcher's pinning, proxy, CA or other
12
+ // security settings. Node's bundled Agent uses different Symbol identities;
13
+ // recognize only its known default configuration and default factory as well.
14
+ // Unknown configurations fail closed when Undici's internals change.
15
+ const referenceAgent = new Agent();
16
+ const factorySymbol = Object.getOwnPropertySymbols(referenceAgent).find(x => x.description === 'factory');
17
+ const defaultFactory = Function.prototype.toString.call(referenceAgent[factorySymbol]).replace(/[\s;]/g, '');
18
+ void referenceAgent.close();
19
+
20
+ function isDefaultAgent(agent) {
21
+ if (! agent || (agent.constructor.name !== 'Agent') || Object.hasOwn(agent, 'dispatch')) {
22
+ return false;
23
+ }
24
+ const symbols = Object.getOwnPropertySymbols(agent);
25
+ const options = agent[symbols.find(x => x.description === 'options')];
26
+ const factory = agent[symbols.find(x => x.description === 'factory')];
27
+ return options && (typeof(factory) === 'function') &&
28
+ (Function.prototype.toString.call(factory).replace(/[\s;]/g, '') === defaultFactory) &&
29
+ Object.entries(options).every(([key, value]) =>
30
+ ((key === 'connect') && (value === undefined)) || ((key === 'maxOrigins') && (value === Infinity)));
31
+ }
32
+
33
+ // System fetch drives this package's Agent through the dispatcher handler
34
+ // API of its own bundled Undici, which changes between major versions.
35
+ function assertCompatibleUndici(bundled = process.versions.undici) {
36
+ const major = version => String(version).split('.')[0];
37
+ if (major(bundled) !== major(undiciVersion)) {
38
+ throw new TypeError(`trFetch uses Undici ${undiciVersion}, which is incompatible with ` +
39
+ `Undici ${bundled} in the fetch of Node.js ${process.version}`);
40
+ }
41
+ }
42
+
43
+ function assertDefaultDispatcher() {
44
+ assertCompatibleUndici();
45
+ if (! isDefaultAgent(getGlobalDispatcher())) {
46
+ throw new TypeError('trFetch requires the default Undici dispatcher; custom or unrecognized global dispatchers cannot be safely replaced');
47
+ }
48
+ }
49
+
50
+ function originOf(options) {
51
+ // Undici passes IPv6 addresses without URL brackets.
52
+ const host = (isIP(options.hostname) === 6) ? `[${options.hostname}]` : options.hostname;
53
+ try {
54
+ return new URL(`${options.protocol}//${host}${options.port ? `:${options.port}` : ''}`).origin;
55
+ } catch (_) {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function createAgent(checkCertificate, signal) {
61
+ const connect = buildConnector({ rejectUnauthorized: true, maxCachedSessions: 0, allowH2: false });
62
+ const origins = new Set();
63
+ const agent = new Agent({
64
+ allowH2: false,
65
+ pipelining: 0,
66
+ connect(options, callback) {
67
+ let socket;
68
+ let finished = false;
69
+ function finish(error) {
70
+ if (finished) {
71
+ return;
72
+ }
73
+ finished = true;
74
+ signal?.removeEventListener('abort', abort);
75
+ socket?.removeListener('error', finish);
76
+ if (error) {
77
+ socket?.destroy();
78
+ callback(error, null);
79
+ } else {
80
+ origins.add(originOf(options));
81
+ callback(null, socket);
82
+ }
83
+ }
84
+ function abort() {
85
+ finish(signal.reason);
86
+ }
87
+ if (signal?.aborted) {
88
+ abort();
89
+ return;
90
+ }
91
+ signal?.addEventListener('abort', abort, { once: true });
92
+ // Bind SNI and identity verification to the URL, not a Host header.
93
+ const hostname = options.hostname;
94
+ socket = connect({ ...options, host: hostname, servername: isIP(hostname) ? null : hostname }, function(error, connected) {
95
+ if (finished) {
96
+ connected?.destroy();
97
+ return;
98
+ }
99
+ if (error) {
100
+ finish(error);
101
+ return;
102
+ }
103
+ if (options.protocol !== 'https:') {
104
+ finish();
105
+ return;
106
+ }
107
+ if (! connected.authorized) {
108
+ finish(new Error(`TLS certificate verification failed: ${connected.authorizationError}`));
109
+ return;
110
+ }
111
+ connected.disableRenegotiation();
112
+ if (! checkCertificate) {
113
+ finish();
114
+ return;
115
+ }
116
+ Promise.resolve().then(function() {
117
+ return checkCertificate(connected.getPeerCertificate(true), hostname, signal);
118
+ }).then(() => finish(), finish);
119
+ });
120
+ socket.on('error', finish);
121
+ return socket;
122
+ }
123
+ });
124
+ verifiedOrigins.set(agent, origins);
125
+ return agent;
126
+ }
127
+
128
+ // Fail closed if system fetch was replaced or wrapped by code that dropped
129
+ // the dispatcher: an HTTP(S) response must come from a connection that this
130
+ // agent opened and verified, both for the request and the final URL.
131
+ function assertVerifiedResponse(agent, requestUrl, response) {
132
+ if (! [ 'http:', 'https:' ].includes(new URL(requestUrl).protocol)) {
133
+ return;
134
+ }
135
+ const origins = verifiedOrigins.get(agent);
136
+ for (const value of [ requestUrl, response.url || requestUrl ]) {
137
+ let origin;
138
+ try {
139
+ origin = new URL(value).origin;
140
+ } catch (_) {
141
+ origin = undefined;
142
+ }
143
+ if (! origins?.has(origin)) {
144
+ throw new TypeError(`trFetch: the response for ${debugUrl(value)} did not come through the verifying ` +
145
+ 'dispatcher; globalThis.fetch may be replaced or wrapped');
146
+ }
147
+ }
148
+ }
149
+
150
+ module.exports = { assertCompatibleUndici, assertDefaultDispatcher, assertVerifiedResponse, createAgent };