UNPKG

gohttp

Version:

http & https client for HTTP/1.1 and HTTP/2

992 lines (780 loc) 21 kB
'use strict'; //const process = require('node:process'); const http2 = require('node:http2') const crypto = require('node:crypto') const fs = require('node:fs') const urlparse = require('node:url') const qs = require('./qs.js') const bodymaker = require('./bodymaker.js') const fmtpath = require('./fmtpath.js') function parseUrl(url) { let urlobj = new urlparse.URL(url); let headers = { ':method' : 'GET', ':path': urlobj.pathname + urlobj.search, } return { hash : urlobj.hash, host : urlobj.host, hostname : urlobj.hostname, protocol : urlobj.protocol, pathname : urlobj.pathname, path: headers[':path'], headers: headers } } //Content-Range: <unit> <range-start>-<range-end>/<size> //Content-Range: <unit> <range-start>-<range-end>/* //Content-Range: <unit> */<size> //unit 是单位,通常按照字节表示:bytes /** * 此处解析只认为unit是bytes。 * @param {string} range */ function parseContentRange(range) { let rsplit = range.split(' ').filter(p => p.length > 0) if (rsplit.length < 2) { return null } let robj = { start : '*', end : '*', size : '*' } let parseStartEnd = (rst) => { if (rst === '*') { return robj } let mind = rst.indexOf('-') if (mind < 0) { return null } robj.start = parseInt(rst.substring(0, mind)) if (mind < rst.length - 1) { robj.end = parseInt(rst.substring(mind+1)) } return robj } let slashIndex = rsplit[1].indexOf('/') if (slashIndex === 0) { return null } if (slashIndex > 0) { let bytes_arr = rsplit[1].split('/') if (bytes_arr.length < 2) { return null } if (bytes_arr[1] !== '*') { robj.size = parseInt(bytes_arr[1]) } return parseStartEnd(bytes_arr[0]) } return parseStartEnd(rsplit[1]) } async function payload(reqobj, mkbody) { let needbody = false if (reqobj.method[0] === 'P') { needbody = true } else if (reqobj.method[0] === 'D' && reqobj.body) { needbody = true } if (!needbody) { return true } if (reqobj.body === undefined) { throw new Error(`${reqobj.method} must with body data`) } //直接转发请求过来的数据。 if (reqobj.body instanceof Buffer) { return 'body' } let bodytype = typeof reqobj.body if (bodytype === 'string' && reqobj.headers['content-type'] === undefined) { reqobj.headers['content-type'] = 'text/plain' } if (bodytype === 'object' && reqobj.headers['content-type'] === undefined) { reqobj.headers['content-type'] = 'application/x-www-form-urlencoded' } if (bodytype === 'string') { reqobj.headers['content-length'] = Buffer.byteLength(reqobj.body) return 'body' } else if (reqobj.multipart && bodytype === 'object') { let tmpbody = await mkbody.makeUploadData(reqobj.body) reqobj.headers['content-type'] = tmpbody['content-type'] reqobj.headers['content-length'] = tmpbody['content-length'] //reqobj._bodyData = tmpbody.body return tmpbody } else if (bodytype === 'object') { let tmpbody = { body : '' } if (reqobj.headers['content-type'] === 'application/x-www-form-urlencoded') { tmpbody.body = Buffer.from(qs(reqobj.body)) } else { tmpbody.body = Buffer.from(JSON.stringify(reqobj.body)) } reqobj.headers['content-length'] = tmpbody.length return tmpbody } } /** * release self when session closed */ let _methodList = [ 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'HEAD', 'TRACE' ] class _response { constructor () { this.headers = null this.status = 0 this.data = null this.timeout = false this.error = null this.ok = null this.buffers = [] this.length = 0 this.contentLength = 0 } text () { if (this.data !== null) { return this.data.toString() } return 'null' } json () { return JSON.parse(this.text()) } blob () { return this.data } } async function _download(stream, reqobj, ret, bkey) { if (!reqobj.dir) { reqobj.dir = './' } else if (reqobj.dir.length > 0 && reqobj.dir[ reqobj.dir.length - 1 ] !== '/') { reqobj.dir += '/' } let _writeStream let onResponse = (headers, flags) => { ret.headers = headers ret.status = parseInt(headers[':status'] || 0) if (ret.status > 0 && ret.status < 400) { ret.ok = true } else { ret.ok = false } if (ret.status < 200 || ret.status > 299) { return } let filename = '' if(headers['content-disposition']) { let name_split = headers['content-disposition'] .split(';') .filter(p => p.length > 0) for(let i=0; i < name_split.length; i++) { if (name_split[i].indexOf('filename*=') >= 0) { filename = name_split[i].trim().substring(10) filename = filename.split('\'')[2] filename = decodeURIComponent(filename) } else if(name_split[i].indexOf('filename=') >= 0) { filename = name_split[i].trim().substring(9) } } } if (headers['content-length']) { ret.contentLength = parseInt(headers['content-length']) } if (!filename) { let h = crypto.createHash('sha1') h.update(`${Date.now()}${Math.random()}`) filename = h.digest('hex') } let target = reqobj.target || `${reqobj.dir}${filename}` let wstm_opts = { encoding : 'binary' } /** * 存在content-range并且已经存在对应名称的文件: * - 解析content-range * - 检测文件尺寸 * - 对比是否对应 */ if (headers['content-range']) { let range = parseContentRange(headers['content-range']) if (range && range.start !== '*') { try { let fst = fs.statSync(filename) if (fst.size === range.size) { stream.emit('end') return } if (range.start > 0 && fst.size === range.start) { wstm_opts.flags = 'a+' } } catch (err) {} } } try { _writeStream = fs.createWriteStream(target, wstm_opts) } catch (err) { stream.emit('error', err) } } return new Promise((rv, rj) => { stream.on('response', onResponse) stream.on('timeout', () => { ret.ok = false ret.timeout = true stream.close() rv(ret) }) stream.on('data', chunk => { ret.length += chunk.length _writeStream.write(chunk) if (reqobj.ondata && typeof reqobj.ondata === 'function') { reqobj.ondata(ret) } }) stream.on('end', () => { stream.removeAllListeners() stream.close() rv(ret) }) stream.on('error', err => { stream.removeAllListeners() stream.close() ret.error = err rv(ret) }) stream.on('frameError', err => { stream.removeAllListeners() stream.close() ret.error = err rv(ret) }) if (bkey !== true) { if (typeof bkey === 'object') { stream.end(bkey.body) } else { stream.end(reqobj[bkey]) } } else { stream.end() } }) .finally(() => { if (_writeStream) { _writeStream.end() } }) } class _Request { constructor(options) { this.session = options.session this.url = options.url this.bodymaker = options.bodymaker this.parent = options.parent this.pending = options.pending this.debug = options.debug this.keepalive = options.keepalive this.reconnDelay = options.reconnDelay || 0 this.connected = false this.connecting = false this.goaway = false this.headers = null Object.defineProperty(this, '__prefix__', { enumerable: false, configurable: false, writable: true, value: fmtpath(options.prefix || '') }) Object.defineProperty(this, 'prefix', { set: function (path) { this.__prefix__ = fmtpath(path) }, get: function () { return this.__prefix__ } }) if (options.headers) this.setHeader(options.headers) this.init() } init() { this.session.on('connect', () => { this.connected = true this.connecting = false this.goaway = false }) this.session.on('goaway', () => { this.goaway = true }) this.session.on('close', async () => { this.connected = false this.connecting = false if (this.keepalive && typeof this.reconn === 'function') { if (this.reconnDelay && this.reconnDelay > 0) { await new Promise((rv, rj) => { setTimeout(() => { rv() }, this.reconnDelay) }) } if (this.connected) return this.debug && console.log('Connect closed, reconnect...') this.reconn() } else { this.free() } }) this.session.on('frameError', err => { this.debug && console.error(err) this.connected = false this.session.destroy() }) this.session.on('error', err => { this.debug && console.error(err) this.connected = false this.session.destroy() /** * 如果没有连接上,再次重连失败会触发error,但是不会触发connect、close,因此,不会继续发起重连。 * 此处会在错误的时候继续发起重连过程。 */ if (this.keepalive && !this.connected && !this.connecting) { setTimeout(() => { if (!this.connected && !this.connecting) { this.reconn && this.reconn() } }, (isNaN(this.reconnDelay) ? 0 : this.reconnDelay) + 100) } }) } close() { if (this.session && !this.session.closed) { this.session.close() } } destroy() { if (this.session && !this.session.destroyed) { this.session.destroy() } } on(evt, callback) { this.session.on(evt, callback) return this } free() { this.parent._freeRequest(this) } setHeader(key, val) { if (!this.headers) this.headers = {} if (typeof key === 'object') { for (let k in key) this.headers[ k.toLowerCase() ] = key[k] } else { this.headers[ key.toLowerCase() ] = val } return this } /** * { * method : 'GET', * path : '/', * body : BODY, * data : DATA, * query : {}, * files : FILES, * headers : {}, * options : {} * } * @param {object} reqobj */ checkAndSetOptions(reqobj) { if (reqobj.headers === undefined || typeof reqobj.headers !== 'object') { reqobj.headers = {} } if (this.headers && typeof this.headers === 'object') { for (let k in this.headers) { if (reqobj.headers[k] === undefined) reqobj.headers[k] = this.headers[k] } } if (reqobj.method === undefined || _methodList.indexOf(reqobj.method) < 0) { reqobj.method = 'GET' } if (reqobj.path === undefined || reqobj.path === '') { reqobj.path = '/' } if (reqobj.path[0] !== '/') { reqobj.path = `/${reqobj.path}` } if (reqobj.query) { let qstr; let qchar = '?'; if (typeof reqobj.query === 'object') { qstr = qs(reqobj.query) } else { qstr = reqobj.query } if (reqobj.path.indexOf('?') > 0) qchar = '&' reqobj.path += qchar + qstr } if (reqobj.timeout === undefined) { reqobj.timeout = 15000 } if (this.__prefix__ && !reqobj.withoutPrefix) { reqobj.headers[':path'] = `${this.prefix}${reqobj.path}` } else { reqobj.headers[':path'] = reqobj.path } reqobj.headers[':method'] = reqobj.method if (reqobj.signal) { if (!reqobj.options || typeof reqobj.options !== 'object') { reqobj.options = {} } reqobj.options.signal = reqobj.signal } } async request(reqobj, events = {}) { this.checkAndSetOptions(reqobj) let rb = await payload(reqobj, this.bodymaker) if (!this.session || this.session.destroyed) { if (this.keepalive) { this.reconn() } else { throw new Error(`session is destroyed, please reconnect`) } } let stm if (reqobj.options) { stm = this.session.request(reqobj.headers, reqobj.options) } else { stm = this.session.request(reqobj.headers) } let ret = new _response() if (reqobj.selfHandle) { return { bodykey: rb, stream: stm, ret: ret } } if (events.response && typeof events.response === 'function') { stm.on('response', events.response) } else { stm.on('response', (headers, flags) => { ret.headers = headers ret.status = parseInt(headers[':status'] || 0) if (ret.status > 0 && ret.status < 400) { ret.ok = true } else { ret.ok = false } }) } return new Promise((rv, rj) => { stm.on('timeout', () => { ret.ok = false ret.timeout = true stm.close(http2.constants.NGHTTP2_CANCEL) rv(ret) }) if (events.data && typeof events.data === 'function') { stm.on('data', events.data) } else { stm.on('data', chunk => { ret.buffers.push(chunk) ret.length += chunk.length }) } stm.on('end', () => { if (ret.buffers && ret.buffers.length > 0) { ret.data = Buffer.concat(ret.buffers, ret.length) ret.buffers = null } stm.removeAllListeners() stm.close() rv(ret) }) stm.on('error', err => { stm.removeAllListeners() stm.close(http2.constants.NGHTTP2_INTERNAL_ERROR) ret.error = err rv(ret) }) stm.on('frameError', err => { stm.removeAllListeners() stm.close(http2.constants.NGHTTP2_INTERNAL_ERROR) ret.error = err rv(ret) }) if (rb !== true) { if (typeof rb === 'object') { stm.end(rb.body) } else { stm.end(reqobj[rb]) } } else { stm.end() } }) } async get(reqobj) { reqobj.method = 'GET' return this.request(reqobj) } async post(reqobj) { reqobj.method = 'POST' return this.request(reqobj) } async put(reqobj) { reqobj.method = 'PUT' return this.request(reqobj) } async path(reqobj) { reqobj.method = 'PATCH' return this.request(reqobj) } async delete(reqobj) { reqobj.method = 'DELETE' return this.request(reqobj) } async options(reqobj) { reqobj.method = 'OPTIONS' return this.request(reqobj) } async upload(reqobj) { reqobj.multipart = true if (reqobj.method === undefined) { reqobj.method = 'POST' } if (reqobj.body === undefined) { reqobj.body = {} } if (reqobj.form) { reqobj.body.form = reqobj.form } if (reqobj.files) { reqobj.body.files = reqobj.files } return this.request(reqobj) } async up(reqobj) { reqobj.files = {} reqobj.files[ reqobj.name ] = reqobj.file return this.upload(reqobj) } async download(reqobj) { reqobj.selfHandle = true let r = await this.request(reqobj) return _download(r.stream, reqobj, r.ret, r.bodykey) } } /** * 多个session负载均衡,提高客户端请求效率。 */ class SessionPool { constructor(options = {}) { this.max = 50 this.pool = [] this.step = -1 for (let k in options) { switch (k) { case 'max': this.max = options.max break } } } request(reqobj, events = {}) { return this.getSession().request(reqobj, events) } get(reqobj) { return this.getSession().get(reqobj) } post(reqobj) { return this.getSession().post(reqobj) } put(reqobj) { return this.getSession().put(reqobj) } patch(reqobj) { return this.getSession().patch(reqobj) } delete(reqobj) { return this.getSession().delete(reqobj) } options(reqobj) { return this.getSession().options(reqobj) } upload(reqobj) { return this.getSession().upload(reqobj) } up(reqobj) { return this.getSession().up(reqobj) } download(reqobj) { return this.getSession().download(reqobj) } destroy() { for (let i = 0; i < this.pool.length; i++) { this.pool[i].destroy() } } close() { for (let i = 0; i < this.pool.length; i++) { this.pool[i].close() } } getSession() { if (this.pool.length <= 0) { return null } for (let sess of this.pool) { if (sess.connected && !sess.goaway) { return sess } } return this.pool[0] } add(sess) { if (this.pool.length < this.max) { this.pool.push(sess) } } on(evt, callback) { for (let a of this.pool) { a.on(evt, callback) } } } /** connect --> session --> session.request --> stream1 --> stream2 --> stream3 ... ... */ let hiio = function () { if (!(this instanceof hiio)) { return new hiio() } //process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; this.bodyMethods = 'PD' this.noBodyMethods = 'GOHT' this.bodymaker = new bodymaker() this.pool = [] this.maxPool = 1000 } hiio.prototype._freeRequest = function (req) { if (this.pool.length < this.maxPool) { req.pending = true req.session = null req.url = '' req.connected = false req.connecting = false req.headers = null req.prefix = '' this.pool.push(req) } } hiio.prototype._getPool = function (options) { let r = this.pool.pop() if (r) { r.__prefix__ = options.prefix options.headers && r.setHeader(options.headers) r.connected = true r.session = options.session r.url = options.url r.pending = false r.parent = options.parent r.bodymaker = options.bodymaker r.debug = options.debug === undefined ? false : options.debug r.keepalive = options.keepalive === undefined ? false : options.keepalive r.init() return r } return null } hiio.prototype._newRequest = function (options) { return this._getPool(options) || new _Request(options) } hiio.prototype.parseUrl = parseUrl hiio.prototype.connect = function (url, options = {}) { let urlobj = parseUrl(url); if (!options || typeof options !== 'object') options = {}; let prefix = '' if (urlobj.pathname && urlobj.pathname !== '/') { prefix = fmtpath(urlobj.pathname) } if (options.requestCert === undefined) { options.requestCert = false } if (options.rejectUnauthorized === undefined) { options.rejectUnauthorized = false } if (options.peerMaxConcurrentStreams === undefined) { options.peerMaxConcurrentStreams = 100 } if (options.settings === undefined) { options.settings = { maxHeaderListSize: 16368, maxConcurrentStreams: 100 } } if (options.checkServerIdentity === undefined) { options.checkServerIdentity = (name, cert) => {} } let h = http2.connect(url, options) if (options.timeout && typeof options.timeout === 'number') { h.setTimeout(options.timeout, () => { h.close() }) } if (options.sessionRequest) { options.sessionRequest.session = h return options.sessionRequest } let newReq = this._newRequest({ prefix: prefix, session : h, url : url, headers: options.headers, bodymaker : this.bodymaker, parent : this, pending: false, debug: !!options.debug, keepalive : !!options.keepalive, reconnDelay : options.reconnDelay === undefined ? 500 : options.reconnDelay }) /** * 延迟重连不能使用setTimeout,因为timer循环会在下一个循环开始执行, * 而在本次循环,error的处理会先执行,此时,error事件还没有监听。 */ if (options.keepalive) { /** * 重连阶段,如果未能连接,则会导致connect、close事件无法触发。 */ newReq.reconn = () => { options.sessionRequest = newReq newReq.connecting = true this.connect(url, options) newReq.init() } } return newReq } hiio.prototype.connectPool = function (url, options = {}) { if (options.max === undefined || options.max <= 0) { options.max = 5 } let max = options.max let sp = new SessionPool({max : max}) delete options.max if (!options.keepalive) { options.keepalive = true } for (let i = 0; i < max; i++) { sp.add( this.connect(url, options) ) } return sp } module.exports = hiio