fcoin/lib/http/request.js
2017-08-07 15:56:15 -07:00

664 lines
14 KiB
JavaScript

/*
* request.js - http request for bcoin
* Copyright (c) 2014-2017, Christopher Jeffrey (MIT License).
* https://github.com/bcoin-org/bcoin
*/
'use strict';
const Stream = require('stream').Stream;
const assert = require('assert');
let url, qs, http, https, StringDecoder;
/*
* Constants
*/
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1)'
+ ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36';
/**
* Request Options
* @constructor
* @ignore
* @param {Object} options
*/
function RequestOptions(options) {
if (!(this instanceof RequestOptions))
return new RequestOptions(options);
this.uri = 'http://localhost:80/';
this.host = 'localhost';
this.path = '/';
this.port = 80;
this.ssl = false;
this.method = 'GET';
this.strictSSL = true;
this.pool = false;
this.agent = USER_AGENT;
this.type = null;
this.expect = null;
this.query = null;
this.body = null;
this.auth = null;
this.limit = 10 << 20;
this.maxRedirects = 5;
this.timeout = 5000;
this.buffer = false;
this.headers = null;
// Hack
ensureRequires();
if (options)
this.fromOptions(options);
}
RequestOptions.prototype.setURI = function setURI(uri) {
assert(typeof uri === 'string');
if (!/:\/\//.test(uri))
uri = (this.ssl ? 'https://' : 'http://') + uri;
uri = url.parse(uri);
assert(uri.protocol === 'http:' || uri.protocol === 'https:');
this.uri = uri;
this.ssl = uri.protocol === 'https:';
if (uri.search)
this.query = qs.parse(uri.search);
this.host = uri.hostname;
this.path = uri.pathname;
this.port = uri.port || (this.ssl ? 443 : 80);
if (uri.auth) {
const parts = uri.auth.split(':');
this.auth = {
username: parts[0] || '',
password: parts[1] || ''
};
}
};
RequestOptions.prototype.fromOptions = function fromOptions(options) {
if (typeof options === 'string')
options = { uri: options };
if (options.ssl != null) {
assert(typeof options.ssl === 'boolean');
this.ssl = options.ssl;
}
if (options.uri != null)
this.setURI(options.uri);
if (options.url != null)
this.setURI(options.url);
if (options.method != null) {
assert(typeof options.method === 'string');
this.method = options.method.toUpperCase();
}
if (options.strictSSL != null) {
assert(typeof options.strictSSL === 'boolean');
this.strictSSL = options.strictSSL;
}
if (options.pool != null) {
assert(typeof options.pool === 'boolean');
this.pool = options.pool;
}
if (options.agent != null) {
assert(typeof options.agent === 'string');
this.agent = options.agent;
}
if (options.auth != null) {
assert(typeof options.auth === 'object');
assert(typeof options.auth.username === 'string');
assert(typeof options.auth.password === 'string');
this.auth = options.auth;
}
if (options.query != null) {
if (typeof options.query === 'string') {
this.query = qs.stringify(options.query);
} else {
assert(typeof options.query === 'object');
this.query = options.query;
}
}
if (options.json != null) {
assert(typeof options.json === 'object');
this.body = Buffer.from(JSON.stringify(options.json), 'utf8');
this.type = 'json';
}
if (options.form != null) {
assert(typeof options.form === 'object');
this.body = Buffer.from(qs.stringify(options.form), 'utf8');
this.type = 'form';
}
if (options.type != null) {
assert(typeof options.type === 'string');
assert(getType(options.type));
this.type = options.type;
}
if (options.expect != null) {
assert(typeof options.expect === 'string');
assert(getType(options.expect));
this.expect = options.expect;
}
if (options.body != null) {
if (typeof options.body === 'string') {
this.body = Buffer.from(options.body, 'utf8');
} else {
assert(Buffer.isBuffer(options.body));
this.body = options.body;
}
}
if (options.timeout != null) {
assert(typeof options.timeout === 'number');
this.timeout = options.timeout;
}
if (options.limit != null) {
assert(typeof options.limit === 'number');
this.limit = options.limit;
}
if (options.maxRedirects != null) {
assert(typeof options.maxRedirects === 'number');
this.maxRedirects = options.maxRedirects;
}
if (options.buffer != null) {
assert(typeof options.buffer === 'boolean');
this.buffer = options.buffer;
}
if (options.headers != null) {
assert(typeof options.headers === 'object');
this.headers = options.headers;
}
};
RequestOptions.prototype.isExpected = function isExpected(type) {
if (!this.expect)
return true;
return this.expect === type;
};
RequestOptions.prototype.isOverflow = function isOverflow(hdr) {
if (!hdr)
return false;
if (!this.buffer)
return false;
const length = parseInt(hdr, 10);
if (!isFinite(length))
return true;
return length > this.limit;
};
RequestOptions.prototype.getBackend = function getBackend() {
ensureRequires(this.ssl);
return this.ssl ? https : http;
};
RequestOptions.prototype.getHeaders = function getHeaders() {
if (this.headers)
return this.headers;
const headers = Object.create(null);
headers['User-Agent'] = this.agent;
if (this.type)
headers['Content-Type'] = getType(this.type);
if (this.body)
headers['Content-Length'] = this.body.length.toString(10);
if (this.auth) {
const auth = `${this.auth.username}:${this.auth.password}`;
const data = Buffer.from(auth, 'utf8');
headers['Authorization'] = `Basic ${data.toString('base64')}`;
}
return headers;
};
RequestOptions.prototype.toHTTP = function toHTTP() {
let query = '';
if (this.query)
query = '?' + qs.stringify(this.query);
return {
method: this.method,
host: this.host,
port: this.port,
path: this.path + query,
headers: this.getHeaders(),
agent: this.pool ? null : false,
rejectUnauthorized: this.strictSSL
};
};
/**
* Request
* @alias module:http.Request
* @constructor
* @private
* @param {Object} options
*/
function Request(options) {
if (!(this instanceof Request))
return new Request(options);
Stream.call(this);
this.options = new RequestOptions(options);
this.request = null;
this.response = null;
this.statusCode = 0;
this.headers = null;
this.type = 'bin';
this.redirects = 0;
this.timeout = null;
this.finished = false;
this.onResponse = this._onResponse.bind(this);
this.onData = this._onData.bind(this);
this.onEnd = this._onEnd.bind(this);
this.total = 0;
this.decoder = null;
this.body = null;
this.buffer = null;
}
Object.setPrototypeOf(Request.prototype, Stream.prototype);
Request.prototype.startTimeout = function startTimeout() {
if (!this.options.timeout)
return;
this.timeout = setTimeout(() => {
this.finish(new Error('Request timed out.'));
}, this.options.timeout);
};
Request.prototype.stopTimeout = function stopTimeout() {
if (this.timeout != null) {
clearTimeout(this.timeout);
this.timeout = null;
}
};
Request.prototype.cleanup = function cleanup() {
this.stopTimeout();
if (this.request) {
this.request.removeListener('response', this.onResponse);
this.request.removeListener('error', this.onEnd);
this.request.addListener('error', () => {});
}
if (this.response) {
this.response.removeListener('data', this.onData);
this.response.removeListener('error', this.onEnd);
this.response.removeListener('end', this.onEnd);
this.response.addListener('error', () => {});
}
};
Request.prototype.close = function close() {
if (this.request) {
try {
this.request.abort();
} catch (e) {
;
}
}
if (this.response) {
try {
this.response.destroy();
} catch (e) {
;
}
}
this.cleanup();
this.request = null;
this.response = null;
};
Request.prototype.destroy = function destroy() {
this.close();
};
Request.prototype.start = function start() {
const backend = this.options.getBackend();
const options = this.options.toHTTP();
this.startTimeout();
this.request = backend.request(options);
this.response = null;
if (this.options.body)
this.request.write(this.options.body);
this.request.on('response', this.onResponse);
this.request.on('error', this.onEnd);
};
Request.prototype.write = function write(data) {
return this.request.write(data);
};
Request.prototype.end = function end() {
return this.request.end();
};
Request.prototype.finish = function finish(err) {
if (this.finished)
return;
this.finished = true;
if (err) {
this.destroy();
this.emit('error', err);
return;
}
this.cleanup();
if (this.options.buffer) {
assert(this.buffer != null);
switch (this.type) {
case 'bin': {
this.body = Buffer.concat(this.buffer);
this.buffer = null;
break;
}
case 'json': {
const buffer = this.buffer.trim();
this.buffer = null;
if (buffer.length === 0)
break;
let body;
try {
body = JSON.parse(buffer);
} catch (e) {
this.emit('error', e);
return;
}
if (!body || typeof body !== 'object') {
this.emit('error', new Error('JSON body is a non-object.'));
return;
}
this.body = body;
break;
}
case 'form': {
const buffer = this.buffer;
this.buffer = null;
try {
this.body = qs.parse(buffer);
} catch (e) {
this.emit('error', e);
return;
}
break;
}
default: {
this.body = this.buffer;
this.buffer = null;
break;
}
}
}
this.emit('end');
this.emit('close');
};
Request.prototype._onResponse = function _onResponse(response) {
const location = response.headers['location'];
if (location) {
if (++this.redirects > this.options.maxRedirects) {
this.finish(new Error('Too many redirects.'));
return;
}
this.close();
this.options.setURI(location);
this.start();
this.end();
return;
}
const contentType = response.headers['content-type'];
const type = parseType(contentType);
if (!this.options.isExpected(type)) {
this.finish(new Error('Wrong content-type for response.'));
return;
}
const length = response.headers['content-length'];
if (this.options.isOverflow(length)) {
this.finish(new Error('Response exceeded limit.'));
return;
}
this.response = response;
this.statusCode = response.statusCode;
this.headers = response.headers;
this.type = type;
this.response.on('data', this.onData);
this.response.on('error', this.onEnd);
this.response.on('end', this.onEnd);
this.emit('headers', response.headers);
this.emit('type', this.type);
this.emit('response', response);
if (this.options.buffer) {
if (this.type !== 'bin') {
this.decoder = new StringDecoder('utf8');
this.buffer = '';
} else {
this.buffer = [];
}
}
};
Request.prototype._onData = function _onData(data) {
this.total += data.length;
this.emit('data', data);
if (this.options.buffer) {
if (this.options.limit) {
if (this.total > this.options.limit) {
this.finish(new Error('Response exceeded limit.'));
return;
}
}
if (this.decoder) {
this.buffer += this.decoder.write(data);
return;
}
this.buffer.push(data);
}
};
Request.prototype._onEnd = function _onEnd(err) {
this.finish(err);
};
/**
* Make an HTTP request.
* @alias module:http.request
* @param {Object} options
* @param {String} options.uri
* @param {Object?} options.query
* @param {Object?} options.body
* @param {Object?} options.json
* @param {Object?} options.form
* @param {String?} options.type - One of `"json"`,
* `"form"`, `"text"`, or `"bin"`.
* @param {String?} options.agent - User agent string.
* @param {Object?} [options.strictSSL=true] - Whether to accept bad certs.
* @param {Object?} options.method - HTTP method.
* @param {Object?} options.auth
* @param {String?} options.auth.username
* @param {String?} options.auth.password
* @param {String?} options.expect - Type to expect (see options.type).
* Error will be returned if the response is not of this type.
* @param {Number?} options.limit - Byte limit on response.
* @returns {Promise}
*/
function request(options) {
if (typeof options === 'string')
options = { uri: options };
options.buffer = true;
return new Promise((resolve, reject) => {
const req = new Request(options);
req.on('error', err => reject(err));
req.on('end', () => resolve(req));
req.start();
req.end();
});
}
request.stream = function stream(options) {
const req = new Request(options);
req.start();
return req;
};
/*
* Helpers
*/
function parseType(type) {
type = type || '';
type = type.split(';')[0];
type = type.toLowerCase();
type = type.trim();
switch (type) {
case 'text/x-json':
case 'application/json':
return 'json';
case 'application/x-www-form-urlencoded':
return 'form';
case 'text/html':
case 'application/xhtml+xml':
return 'html';
case 'text/xml':
case 'application/xml':
return 'xml';
case 'text/javascript':
case 'application/javascript':
return 'js';
case 'text/css':
return 'css';
case 'text/plain':
return 'txt';
case 'application/octet-stream':
return 'bin';
default:
return 'bin';
}
}
function getType(type) {
switch (type) {
case 'json':
return 'application/json; charset=utf-8';
case 'form':
return 'application/x-www-form-urlencoded; charset=utf-8';
case 'html':
return 'text/html; charset=utf-8';
case 'xml':
return 'application/xml; charset=utf-8';
case 'js':
return 'application/javascript; charset=utf-8';
case 'css':
return 'text/css; charset=utf-8';
case 'txt':
return 'text/plain; charset=utf-8';
case 'bin':
return 'application/octet-stream';
default:
throw new Error(`Unknown type: ${type}.`);
}
}
function ensureRequires(ssl) {
if (!url)
url = require('url');
if (!qs)
qs = require('querystring');
if (!http)
http = require('http');
if (ssl && !https)
https = require('https');
if (!StringDecoder)
StringDecoder = require('string_decoder').StringDecoder;
}
/*
* Expose
*/
module.exports = request;