gohttp
Version:
http & https client for HTTP/1.1 and HTTP/2
493 lines (423 loc) • 16.3 kB
JavaScript
;
const http = require('node:http');
const https = require('node:https');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { URL, URLSearchParams } = require('node:url');
const BodyMaker = require('./bodymaker.js');
// --------------------------------------------------------------------------
// 内部工具函数:替代 fmtpath.js
// 逻辑:格式化前缀,确保以 '/' 开头,且不以 '/' 结尾 (除非是根路径,则返回空字符串用于拼接)
// --------------------------------------------------------------------------
function formatPrefix(p) {
if (typeof p !== 'string') return '';
// 移除末尾的 /
p = p.replace(/\/+$/, '');
if (p.length === 0) return '';
// 确保开头有 /
if (p[0] !== '/') p = '/' + p;
return p;
}
// --------------------------------------------------------------------------
// 1. 全局连接池管理
// --------------------------------------------------------------------------
const agentOptions = {
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 1024,
maxFreeSockets: 256,
timeout: 60000
};
const globalHttpAgent = new http.Agent(agentOptions);
const globalHttpsAgent = new https.Agent(agentOptions);
const globalInsecureAgent = new https.Agent({
...agentOptions,
rejectUnauthorized: false
});
// --------------------------------------------------------------------------
// 2. GoHttp 主类
// --------------------------------------------------------------------------
class GoHttp {
constructor(options = {}) {
if (!(this instanceof GoHttp)) { return new GoHttp(options); }
this.config = {
cert: '',
key: '',
verifyCert: true,
...options
};
if (this.config.cert && fs.existsSync(this.config.cert)) {
this.cert = fs.readFileSync(this.config.cert);
}
if (this.config.key && fs.existsSync(this.config.key)) {
this.key = fs.readFileSync(this.config.key);
}
this.bodymaker = new BodyMaker(options);
this.maxBody = 100 * 1024 * 1024
}
parseUrl(urlStr) {
if (urlStr.startsWith('unix:')) {
const sockarr = urlStr.split('.sock');
return {
protocol: 'http:',
socketPath: `${sockarr[0].substring(5)}.sock`,
path: sockarr[1] || '/',
hostname: 'unix',
headers: {},
method: 'GET'
};
}
try {
const u = new URL(urlStr);
const opts = {
protocol: u.protocol,
hostname: u.hostname,
port: u.port,
path: u.pathname + u.search,
pathname: u.pathname,
search: u.search,
hash: u.hash,
method: 'GET',
headers: {}
};
if (u.protocol === 'https:') {
if (!this.config.verifyCert) {
opts.rejectUnauthorized = false;
} else if (this.cert && this.key) {
opts.cert = this.cert;
opts.key = this.key;
}
}
return opts;
} catch (err) {
throw new Error(`Invalid URL: ${urlStr}`);
}
}
_mergeQuery(opts, queryData) {
if (!queryData) return;
// 替代 qs.js:使用原生 URLSearchParams
let qstr = '';
if (typeof queryData === 'object') {
qstr = new URLSearchParams(queryData).toString();
} else {
qstr = String(queryData);
}
if (!qstr) return;
const separator = opts.path.includes('?') ? '&' : '?';
opts.path += separator + qstr;
}
async request(url, options = null) {
let opts;
if (typeof url === 'string') {
opts = this.parseUrl(url);
} else if (typeof url === 'object' && url !== null) {
opts = { ...url };
if (!opts.path && opts.pathname) opts.path = opts.pathname;
} else {
throw new Error('url must be a string or object');
}
if (opts.timeout === undefined) opts.timeout = 35000;
if (options && typeof options === 'object') {
if (options.headers) opts.headers = { ...opts.headers, ...options.headers };
if (options.query) this._mergeQuery(opts, options.query);
for (let k in options) {
if (k !== 'headers' && k !== 'query') opts[k] = options[k];
}
}
if (!opts.headers) opts.headers = {};
const method = (opts.method || 'GET').toUpperCase();
opts.method = method;
const postState = { isPost: false, bodyStream: null };
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && (opts.body || opts.rawBody)) {
postState.isPost = true;
let contentType = opts.headers['content-type'] || '';
// 优先处理 rawBody,如果提供了 rawBody 则直接使用它
if (opts.rawBody) {
let buf = Buffer.isBuffer(opts.rawBody) ? opts.rawBody : Buffer.from(opts.rawBody);
if (!contentType.includes('multipart')) {
opts.headers['content-length'] = buf.length;
}
postState.bodyStream = buf;
}
// 处理 multipart/form-data,仅当没有 rawBody 时才检查 body.files
else if (contentType.includes('multipart/form-data') || (opts.body && opts.body.files)) {
const boundary = this.bodymaker.generateBoundary();
const length = await this.bodymaker.calculateLength(opts.body, boundary);
opts.headers['content-type'] = `multipart/form-data; boundary=${boundary}`;
opts.headers['content-length'] = length;
postState.bodyStream = this.bodymaker.makeUploadStream(opts.body, boundary);
}
else if (contentType === 'application/x-www-form-urlencoded') {
// 替代 qs.js
const payload = new URLSearchParams(opts.body).toString();
const buf = Buffer.from(payload);
opts.headers['content-length'] = buf.length;
postState.bodyStream = buf;
}
else {
let buf;
if (typeof opts.body === 'object') {
if (!contentType) opts.headers['content-type'] = 'application/json';
buf = Buffer.from(JSON.stringify(opts.body));
} else {
buf = Buffer.from(String(opts.body));
}
if (!contentType.includes('multipart')) {
opts.headers['content-length'] = buf.length;
}
postState.bodyStream = buf;
}
}
if (!opts.agent) {
if (opts.protocol === 'https:') {
opts.agent = (!this.config.verifyCert || opts.rejectUnauthorized === false)
? globalInsecureAgent
: globalHttpsAgent;
} else {
opts.agent = globalHttpAgent;
}
}
if (options && options.isDownload) {
return this._coreDownload(opts, postState);
}
return this._coreRequest(opts, postState);
}
_coreRequest(opts, postState) {
const lib = (opts.protocol === 'https:') ? https : http;
return new Promise((resolve, reject) => {
const req = lib.request(opts, (res) => {
// 检查是否为SSE响应
const contentType = res.headers['content-type'] || '';
const isSSE = contentType.includes('text/event-stream') ||
(contentType.includes('text/plain') && opts.sse && res.statusCode < 300);
// 如果指定了sseCallback且是SSE响应,则使用流式处理
if (opts.sseCallback && isSSE) {
if (opts.encoding) res.setEncoding(opts.encoding);
let sse_options = {headers: res.headers, status: res.statusCode};
res.on('data', (chunk) => {
opts.sseCallback(chunk, sse_options);
});
res.on('end', () => {
opts.sseCallback(null, sse_options); // 通知结束
resolve({
status: res.statusCode,
headers: res.headers,
ok: res.statusCode >= 200 && res.statusCode < 400,
error: null,
timeout: false,
text: () => '', // SSE模式下不返回文本内容
json: () => {}, // SSE模式下不返回JSON
blob: () => Buffer.alloc(0) // SSE模式下不返回blob
});
});
res.on('error', (err) => resolve({ ok: false, error: err, status: 0 }));
} else {
// 传统处理方式
if (opts.encoding) res.setEncoding(opts.encoding);
const chunks = [];
let totalLen = 0;
res.on('data', (chunk) => {
chunks.push(chunk);
totalLen += chunk.length;
// 限制最大 500MB
if (totalLen > this.maxBody) {
req.destroy();
reject(new Error('Response body too large'));
}
});
res.on('end', () => {
const dataBuf = Buffer.concat(chunks, totalLen);
const ret = {
status: res.statusCode,
headers: res.headers,
data: dataBuf,
length: totalLen,
ok: res.statusCode >= 200 && res.statusCode < 400,
error: null,
timeout: false,
text: (encoding = 'utf8') => dataBuf.toString(encoding),
json: (encoding = 'utf8') => JSON.parse(dataBuf.toString(encoding)),
blob: () => dataBuf
};
resolve(ret);
});
res.on('error', (err) => resolve({ ok: false, error: err, status: 0 }));
}
});
if (opts.timeout) {
req.setTimeout(opts.timeout, () => {
req.destroy();
resolve({ ok: false, timeout: true, error: new Error('Timeout'), status: 0 });
});
}
req.on('error', (err) => reject(err));
if (postState.isPost && postState.bodyStream) {
if (typeof postState.bodyStream.pipe === 'function') {
postState.bodyStream.pipe(req);
} else {
req.write(postState.bodyStream);
req.end();
}
} else {
req.end();
}
});
}
_coreDownload(opts, postState) {
const lib = (opts.protocol === 'https:') ? https : http;
const dir = opts.dir || './';
return new Promise((resolve, reject) => {
const req = lib.request(opts, (res) => {
if (res.statusCode >= 400) {
reject(new Error(`Download failed: ${res.statusCode}`));
return;
}
let filename = '';
const cd = res.headers['content-disposition'];
if (cd) {
// 简化版文件名解析
const utf8Match = cd.match(/filename\*=utf-8''(.+)/i);
if (utf8Match) {
filename = decodeURIComponent(utf8Match[1]);
} else {
const standardMatch = cd.match(/filename="?([^";]+)"?/i);
if (standardMatch) filename = standardMatch[1];
}
}
if (!filename) filename = crypto.createHash('md5').update(Date.now().toString()).digest('hex');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
let targetPath = path.join(dir, filename);
if (fs.existsSync(targetPath)) targetPath = path.join(dir, `${Date.now()}-${filename}`);
const fileStream = fs.createWriteStream(targetPath);
// 进度条逻辑
if (opts.progress) {
const total = parseInt(res.headers['content-length'] || 0);
let cur = 0;
let lastLog = 0;
res.on('data', c => {
cur += c.length;
if (total > 0 && Date.now() - lastLog > 500) {
process.stdout.write(`Downloading: ${((cur/total)*100).toFixed(1)}%\r`);
lastLog = Date.now();
}
});
}
res.pipe(fileStream);
fileStream.on('finish', () => {
if (opts.progress) console.log('\nDone.');
resolve(true);
});
fileStream.on('error', (err) => {
fs.unlink(targetPath, () => {});
reject(err);
});
});
req.on('error', reject);
if (postState.isPost && postState.bodyStream) {
typeof postState.bodyStream.pipe === 'function' ? postState.bodyStream.pipe(req) : req.end(postState.bodyStream);
} else {
req.end();
}
});
}
// --- 便捷方法 ---
checkMethod(method, options) {
if (typeof options !== 'object') return { method };
options.method = method;
return options;
}
async get(url, options = {}) { return this.request(url, this.checkMethod('GET', options)); }
async post(url, options = {}) { return this.request(url, this.checkMethod('POST', options)); }
async put(url, options = {}) { return this.request(url, this.checkMethod('PUT', options)); }
async patch(url, options = {}) { return this.request(url, this.checkMethod('PATCH', options)); }
async delete(url, options = {}) { return this.request(url, this.checkMethod('DELETE', options)); }
async options(url, options = {}) { return this.request(url, this.checkMethod('OPTIONS', options)); }
async upload(url, options = {}) {
options = options || {};
options.method = 'POST';
if (!options.body && (options.files || options.form)) {
options.body = { files: options.files, form: options.form };
delete options.files; delete options.form;
}
options.headers = options.headers || {};
options.headers['content-type'] = 'multipart/form-data';
return this.request(url, options);
}
async up(url, opts = {}) {
if (!opts.file) throw new Error('file required');
return this.upload(url, { ...opts, files: { [opts.name || 'file']: opts.file } });
}
async download(url, options = {}) {
options = options || {};
options.method = 'GET';
options.isDownload = true;
return this.request(url, options);
}
transmit(url, opts = {}) {
const uobj = (typeof url === 'string') ? this.parseUrl(url) : { ...url };
if (opts.headers) uobj.headers = { ...uobj.headers, ...opts.headers };
uobj.timeout = opts.timeout || 35000;
uobj.method = opts.method || 'GET';
return this._coreRequest(uobj, { isPost: !!opts.rawbody, bodyStream: opts.rawbody || null });
}
connect(url, options = null) { return new HiiCompat(url, options || {}, this); }
}
// --------------------------------------------------------------------------
// 3. 兼容层
// --------------------------------------------------------------------------
class HiiCompat {
constructor(url, options, goInstance) {
this.req = goInstance;
this.url = url;
this.options = options;
this.urlobj = this.req.parseUrl(url);
this.host = this.urlobj.hostname;
this.port = this.urlobj.port;
this.headers = options.headers ? { ...options.headers } : null;
// 使用新的工具函数
this.__prefix__ = formatPrefix(this.urlobj.pathname);
}
get prefix() { return this.__prefix__; }
set prefix(path) { this.__prefix__ = formatPrefix(path); }
setHeader(key, val) {
if (!this.headers) this.headers = {};
if (typeof key === 'object') Object.assign(this.headers, key);
else this.headers[key] = val;
return this;
}
_makeOpts(opts, method) {
const finalOpts = { ...this.options, ...opts };
finalOpts.headers = { ...this.headers, ...finalOpts.headers };
finalOpts.method = method;
let reqPath = finalOpts.path || '';
if (this.__prefix__ && !finalOpts.withoutPrefix) {
if (!reqPath.startsWith(this.__prefix__)) {
// 简单的路径拼接,避免重复斜杠
reqPath = (this.__prefix__ + '/' + reqPath).replace('//', '/');
}
}
if (finalOpts.query) {
const qs = new URLSearchParams(finalOpts.query).toString();
reqPath += (reqPath.includes('?') ? '&' : '?') + qs;
}
finalOpts.hostname = this.urlobj.hostname;
finalOpts.port = this.urlobj.port;
finalOpts.protocol = this.urlobj.protocol;
finalOpts.path = reqPath;
return finalOpts;
}
upload(opts) { return this.req.upload(this._makeOpts(opts, 'POST')); }
up(opts) { return this.req.up(this._makeOpts(opts, 'POST')); }
download(opts) {
const o = this._makeOpts(opts, 'GET');
o.isDownload = true;
return this.req.request(o);
}
}
;['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'].forEach(method => {
HiiCompat.prototype[method.toLowerCase()] = function(opts = {}) {
return this.req.request(this._makeOpts(opts, method));
};
});
module.exports = GoHttp;