superagent: rewrite using native node request
the learning is that fetch() is really meant to be a browser side XMLHttpRequest replacement. It's complicated to do things like setting user agent, custom headers like Host, disabling tls validation etc.
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -50,7 +50,6 @@
|
||||
"tar-stream": "^3.1.7",
|
||||
"tldjs": "^2.3.1",
|
||||
"ua-parser-js": "^2.0.2",
|
||||
"undici": "^7.3.0",
|
||||
"uuid": "^11.0.5",
|
||||
"validator": "^13.12.0",
|
||||
"ws": "^8.18.0",
|
||||
@@ -8129,15 +8128,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.3.0.tgz",
|
||||
"integrity": "sha512-Qy96NND4Dou5jKoSJ2gm8ax8AJM/Ey9o9mz7KN1bb9GP+G0l20Zw8afxTnY2f4b7hmhn/z8aC2kfArVQlAhFBw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"tar-stream": "^3.1.7",
|
||||
"tldjs": "^2.3.1",
|
||||
"ua-parser-js": "^2.0.2",
|
||||
"undici": "^7.3.0",
|
||||
"uuid": "^11.0.5",
|
||||
"validator": "^13.12.0",
|
||||
"ws": "^8.18.0",
|
||||
|
||||
@@ -11,10 +11,12 @@ exports = module.exports = {
|
||||
};
|
||||
|
||||
// IMPORTANT: do not require box code here . This is used by migration scripts
|
||||
const { Agent } = require('undici'),
|
||||
assert = require('assert'),
|
||||
const assert = require('assert'),
|
||||
consumers = require('node:stream/consumers'),
|
||||
debug = require('debug')('box:superagent'),
|
||||
fs = require('fs'),
|
||||
http = require('http'),
|
||||
https = require('https'),
|
||||
path = require('path'),
|
||||
safe = require('safetydance');
|
||||
|
||||
@@ -23,60 +25,89 @@ class Request {
|
||||
assert.strictEqual(typeof url, 'string');
|
||||
|
||||
this.url = new URL(url);
|
||||
this.fetchOptions = {
|
||||
this.options = {
|
||||
method,
|
||||
headers: new Headers(),
|
||||
redirect: 'follow'
|
||||
headers: {},
|
||||
signal: null // set for timeouts
|
||||
};
|
||||
this.okFunc = (response) => response.ok;
|
||||
this.okFunc = ({ status }) => status >=200 && status <= 299;
|
||||
this.timer = { timeout: 0, id: null, controller: null };
|
||||
this.retryCount = 0;
|
||||
this.body = null;
|
||||
this.redirectCount = 5;
|
||||
}
|
||||
|
||||
async _makeRequest() {
|
||||
const response = await fetch(this.url.toString(), this.fetchOptions); // will only throw with network error or bad scheme
|
||||
async _handleResponse(url, response) {
|
||||
// const contentLength = response.headers['content-length'];
|
||||
// if (!contentLength || contentLength > 5*1024*1024*1024) throw new Error(`Response size unknown or too large: ${contentLength}`);
|
||||
const [consumeError, data] = await safe(consumers.buffer(response)); // have to drain response
|
||||
if (consumeError) throw new Error(`Error consuming body stream: ${consumeError.message}`);
|
||||
if (!response.complete) throw new Error('Incomplete response');
|
||||
const contentType = response.headers['content-type'];
|
||||
|
||||
const result = {
|
||||
url: response.url, // can be different from original when response.redirected
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
url: new URL(response.headers['location'] || '', url),
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: null,
|
||||
text: null
|
||||
};
|
||||
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
const data = await response.arrayBuffer();
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
result.text = Buffer.from(data).toString('utf8');
|
||||
if (data.byteLength !== 0) result.body = JSON.parse(result.text);
|
||||
result.text = data.toString('utf8');
|
||||
if (data.byteLength !== 0) result.body = safe.JSON.parse(result.text) || {};
|
||||
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
|
||||
result.text = Buffer.from(data).toString('utf8');
|
||||
result.text = data.toString('utf8');
|
||||
const searchParams = new URLSearchParams(data);
|
||||
result.body = Object.fromEntries(searchParams.entries());
|
||||
} else if (!contentType) {
|
||||
result.body = Buffer.from(data);
|
||||
} else if (!contentType || contentType.startsWith('text/')) {
|
||||
result.body = data;
|
||||
result.text = result.body.toString('utf8');
|
||||
} else {
|
||||
result.body = Buffer.from(data);
|
||||
result.body = data;
|
||||
result.text = `<binary data (${data.byteLength} bytes)>`;
|
||||
}
|
||||
|
||||
if (!this.okFunc(response)) {
|
||||
const error = new Error(`${response.status} ${response.statusText}`);
|
||||
Object.assign(error, result);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async _makeRequest(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proto = url.protocol === 'https:' ? https : http;
|
||||
const request = proto.request(url, this.options); // ClientRequest
|
||||
|
||||
request.on('error', reject); // network error, dns error
|
||||
request.on('response', async (response) => {
|
||||
const [error, result] = await safe(this._handleResponse(url, response));
|
||||
if (error) reject(error); else resolve(result);
|
||||
});
|
||||
|
||||
if (this.body) request.write(this.body, 'utf8');
|
||||
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async _start() {
|
||||
let error, response;
|
||||
let error;
|
||||
|
||||
for (let i = 0; i < this.retryCount+1; i++) {
|
||||
if (this.timer.timeout) this.timer.id = setTimeout(() => this.timer.controller.abort(), this.timer.timeout);
|
||||
debug(`${this.fetchOptions.method} ${this.url.toString()}` + (i ? ` try ${i+1}` : ''));
|
||||
[error, response] = await safe(this._makeRequest());
|
||||
if (error) debug(`${this.fetchOptions.method} ${this.url.toString()} ${error.message}`);
|
||||
debug(`${this.options.method} ${this.url.toString()}` + (i ? ` try ${i+1}` : ''));
|
||||
|
||||
let response, url = this.url;
|
||||
for (let redirects = 0; redirects < this.redirectCount+1; redirects++) {
|
||||
[error, response] = await safe(this._makeRequest(url));
|
||||
if (error || (response.status < 300 || response.status > 399) || (this.options.method !== 'GET')) break;
|
||||
url = response.url; // follow
|
||||
}
|
||||
|
||||
if (!error && !this.okFunc({ status: response.status })) {
|
||||
error = new Error(`${response.status} ${http.STATUS_CODES[response.status]}`);
|
||||
Object.assign(error, response);
|
||||
}
|
||||
|
||||
if (error) debug(`${this.options.method} ${this.url.toString()} ${error.message}`);
|
||||
if (this.timer.timeout) clearTimeout(this.timer.id);
|
||||
if (!error) return response;
|
||||
}
|
||||
@@ -85,7 +116,7 @@ class Request {
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
this.fetchOptions.headers.set(name, value);
|
||||
this.options.headers[name.toLowerCase()] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -95,17 +126,19 @@ class Request {
|
||||
}
|
||||
|
||||
redirects(count) {
|
||||
this.fetchOptions.redirect = count ? 'follow' : 'manual'; // 'error' makes fetch throw
|
||||
this.redirectCount = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
send(data) {
|
||||
const contentType = this.fetchOptions.headers.get('Content-Type');
|
||||
const contentType = this.options.headers['content-type'];
|
||||
if (!contentType || contentType === 'application/json') {
|
||||
this.fetchOptions.headers.set('Content-Type', 'application/json');
|
||||
this.fetchOptions.body = JSON.stringify(data);
|
||||
this.options.headers['content-type'] = 'application/json';
|
||||
this.body = JSON.stringify(data);
|
||||
this.options.headers['content-length'] = Buffer.byteLength(this.body, 'utf8');
|
||||
} else if (contentType === 'application/x-www-form-urlencoded') {
|
||||
this.fetchOptions.body = new URLSearchParams(data);
|
||||
this.body = (new URLSearchParams(data)).toString();
|
||||
this.options.headers['content-length'] = Buffer.byteLength(this.body, 'utf8');
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@@ -113,7 +146,7 @@ class Request {
|
||||
timeout(msecs) {
|
||||
this.timer.controller = new AbortController();
|
||||
this.timer.timeout = msecs;
|
||||
this.fetchOptions.signal = this.timer.controller.signal;
|
||||
this.options.signal = this.timer.controller.signal;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -128,12 +161,7 @@ class Request {
|
||||
}
|
||||
|
||||
disableTLSCerts() {
|
||||
this.fetchOptions.dispatcher = new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
return this;
|
||||
this.options.rejectUnauthorized = true;
|
||||
}
|
||||
|
||||
auth(username, password) {
|
||||
@@ -142,10 +170,10 @@ class Request {
|
||||
}
|
||||
|
||||
attach(name, filepath) {
|
||||
if (!(this.fetchOptions.body instanceof FormData)) this.fetchOptions.body = new FormData();
|
||||
if (!(this.options.body instanceof FormData)) this.options.body = new FormData();
|
||||
const data = fs.readFileSync(filepath);
|
||||
this.fetchOptions.body.append(name, new Blob([data]), path.basename(filepath));
|
||||
this.fetchOptions.headers.delete('Content-Type'); // explicitly remove it. without it boundary won't make it to the header!
|
||||
this.options.body.append(name, new Blob([data]), path.basename(filepath));
|
||||
delete this.options.headers['content-type']; // explicitly remove it. without it boundary won't make it to the header!
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,37 @@ describe('Superagent', function () {
|
||||
await superagent.get('https://www.cloudron.io').set('User-Agent', 'Mozilla').timeout(10*1000);
|
||||
});
|
||||
|
||||
it('cannot get invalid URL', async function () {
|
||||
const [error] = await safe(superagent.get('htt://www.cloudron.io'));
|
||||
expect(error).to.be.ok();
|
||||
});
|
||||
|
||||
it('cannot get non-existent domain', async function () {
|
||||
const [error] = await safe(superagent.get('https://www.cloudron.io.nxdomain'));
|
||||
expect(error).to.be.ok();
|
||||
});
|
||||
|
||||
it('throws for 404', async function () {
|
||||
const [error] = await safe(superagent.get('https://www.cloudron.io/no-such-page'));
|
||||
expect(error).to.be.ok();
|
||||
expect(error.status).to.be(404);
|
||||
expect(error.text).to.be.a('string');
|
||||
});
|
||||
|
||||
it('can catch a 404', async function () {
|
||||
const response = await superagent.get('https://www.cloudron.io/no-such-page').ok(({status}) => status === 404);
|
||||
expect(response.status).to.be(404);
|
||||
expect(response.text).to.be.a('string');
|
||||
});
|
||||
|
||||
it('did parse json', async function () {
|
||||
const response = await superagent.get('https://ipv4.api.cloudron.io/api/v1/helper/public_ip');
|
||||
expect(response.body.ip).to.be.ok();
|
||||
});
|
||||
|
||||
it('follows redirect', async function () {
|
||||
const response = await superagent.get('https://cloudron.io').set('User-Agent', 'Mozilla').timeout(10*1000);
|
||||
expect(response.url).to.be('https://www.cloudron.io/');
|
||||
expect(response.url.toString()).to.be('https://www.cloudron.io/');
|
||||
});
|
||||
|
||||
it('can disable redirects', async function () {
|
||||
|
||||
Reference in New Issue
Block a user