@ghini/kit
Version:
js practical tools to assist efficient development
716 lines (691 loc) • 21.9 kB
JavaScript
export { req, h2req, h1req, myip, obj2furl, reqdata };
import http2 from "http2";
import https from "https";
import http from "http";
import { empty } from "../index.js";
import { br_decompress, inflate, zstd_decompress, gunzip } from "../codec.js";
import { cerror } from "../console.js";
import os from "os";
import { SocksProxyAgent } from "socks-proxy-agent";
/**
* 只要data数据的req
*/
async function reqdata(...argv) {
return (await req(...argv)).data;
}
// 缓存 HTTP/2 连接
const h2session = new Map();
// 可能性拓展 maxSockets:256 maxSessionMemory:64 maxConcurrentStreams:100 minVersion:'TLSv1.2' ciphers ca cert key
const options_keys = [
"settings",
"cert",
"timeout",
"json",
"auth",
"ua",
"furl",
"proxy",
];
const d_headers = {
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
};
const d_timeout = 30000;
/*
* req直接发请求(适合简单发送)
* @example
* 完整路径+方法(默认get)
* req("https://www.baidu.com?a=1&a=2&b=2")
* req("/test post",body,headers,option)
* req(reqbuild)
*
* timeout 超时时长
* maxsize 最大响应体长度
* certificate 证书
* [api schema validation]
* [CA 信任链]
* @headers
* no-cache
* auto-redirects
*/
// 比h1req h2req多一步detect判断
/** @returns {Promise<ReturnType<typeof resbuild>>} */
async function req(...argv) {
const reqbd = reqbuild(...argv);
try {
if (reqbd.urlobj.protocol === "http:") {
return h1req(reqbd);
}
const sess = await h2connect(reqbd);
if (sess) {
return h2req.bind(sess)(reqbd);
}
return h1req(reqbd);
} catch (error) {
// 明确不能回退的情况
if (reqbd.method.toUpperCase() === "CONNECT") {
return cerror.bind({ info: -1 })("CONNECT method unsupperted");
}
// 其他所有错误都尝试回退到HTTP/1.1
cerror.bind({ info: -1 })(error.code || 'HTTP2_ERROR', "HTTP/2失败,回退到HTTP/1.1");
try {
return await h1req(reqbd);
} catch (fallbackError) {
cerror.bind({ info: -1 })(fallbackError, "HTTP/1.1回退也失败");
return resbuild.bind(reqbd)(false);
}
}
}
// 一般都能马上返回,不用设置超时
// 检测已有h2会话直接拿来用,没有则创建测连
async function h2connect(obj) {
const { urlobj, options } = obj;
const host = urlobj.host;
if (options.proxy) {
return false; // 有代理直接用HTTP/1.1
}
if (h2session.has(host)) {
const session = h2session.get(host);
if (!session.destroyed && !session.closed) {
return session;
} else {
h2session.delete(host);
}
}
return new Promise((resolve, reject) => {
if (!options.servername && !urlobj.hostname.match(/[a-zA-Z]/))
options.servername = "_";
const session = http2.connect(urlobj.origin, {
...{
settings: { enablePush: false },
rejectUnauthorized: false,
},
...options,
});
session.once("connect", () => {
h2session.set(host, session);
return resolve(session);
});
session.once("error", (err) => {
session.destroy();
// 明确需要抛出错误的情况(很少见)
const shouldReject = [
"ENOTFOUND", // 域名不存在
"ECONNREFUSED", // 连接被拒绝且端口明确关闭
].includes(err.code);
if (shouldReject) {
return reject(err);
}
// 其他所有连接错误都静默回退到HTTP/1.1(在上层统一输出日志)
return resolve(false);
});
});
}
/**
* 使用h2为线路复用,会默认保持连接池,所以进程不会自动退出,可用process.exit()主动退出
* @returns {Promise<ReturnType<typeof resbuild>>}
*/
async function h2req(...argv) {
const reqbd = reqbuild(...argv);
let { urlobj, method, headers, body, options } = reqbd;
headers = {
...d_headers,
...headers,
...{
":path": urlobj.pathname + urlobj.search,
":method": method || "GET",
},
};
let req, sess;
try {
sess = this ? this : await h2connect(reqbd);
if (sess === false) throw new Error("H2 connect failed");
req = await sess.request(headers);
if (method === "GET" || method === "DELETE" || method === "HEAD") {
if (!empty(body))
console.warn("NodeJS原生请求限制, ", method, "Body不会生效");
} else {
req.end(body);
}
} catch (error) {
// 直接回退,不判断具体错误类型
cerror.bind({ info: -1 })(error.code || 'HTTP2_ERROR', "HTTP/2请求失败,回退到HTTP/1.1");
try {
return await h1req(reqbd);
} catch (fallbackError) {
cerror.bind({ info: -1 })(fallbackError, "HTTP/1.1回退失败");
return resbuild.bind(reqbd)(false, "h1");
}
}
return new Promise((resolve, reject) => {
req.on("response", (headers, flags) => {
const chunks = [];
req.on("data", (chunk) => {
chunks.push(chunk);
});
req.on("end", () => {
clearTimeout(timeoutId);
const body = Buffer.concat(chunks);
headers = Object.keys(headers).reduce((obj, key) => {
obj[key] = headers[key];
return obj;
}, {});
const code = headers[":status"] || 200;
delete headers[":status"];
resolve(resbuild.bind(reqbd)(true, "h2", code, headers, body));
});
});
const timeout = options.timeout || d_timeout;
const timeoutId = setTimeout(() => {
req.close();
cerror.bind({ info: -1 })(`H2 req timeout >${timeout}ms`, urlobj.host);
resolve(resbuild.bind(reqbd)(false, "h2", 408));
}, timeout);
req.on("error", (err) => {
clearTimeout(timeoutId);
// 明确不能回退的错误(很少见)
const cannotFallback = [
"ERR_HTTP2_INVALID_PSEUDOHEADER", // 伪头部错误,HTTP/1.1也不会成功
"ERR_HTTP2_HEADERS_OBJECT", // 头部格式错误
].includes(err.code);
if (cannotFallback) {
cerror.bind({ info: -1 })(err);
resolve(resbuild.bind(reqbd)(false, "h2"));
return;
}
// 其他所有错误都回退到HTTP/1.1
cerror.bind({ info: -1 })(err.code || 'HTTP2_ERROR', "HTTP/2请求错误,回退到HTTP/1.1");
try {
return resolve(h1req(reqbd));
} catch (error) {
cerror.bind({ info: -1 })(error, "HTTP/1.1回退失败");
return resolve(resbuild.bind(reqbd)(false));
}
});
});
}
// 创建代理Agent的辅助函数
function getAgent(protocol, options) {
if (options?.proxy) {
if (!options.proxy.match("://"))
options.proxy = "socks5://" + options.proxy;
return new SocksProxyAgent(options.proxy);
} else {
if (protocol === "https:") {
return httpsAgent;
} else {
return httpAgent;
}
}
}
// 创建全局 agent
const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 60000,
});
const httpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 60000,
});
// HTTP/1.1 请求
/** @returns {Promise<ReturnType<typeof resbuild>>} */
async function h1req(...argv) {
const reqbd = reqbuild(...argv);
let { urlobj, method, body, headers, options } = reqbd;
// console.dev("h1", urlobj.protocol, method, body);
const protocol = urlobj.protocol === "https:" ? https : http;
// 获取合适的agent,支持代理
const agent = getAgent(urlobj.protocol, options);
const new_headers = {
...d_headers,
...headers,
};
options = {
...{
protocol: urlobj.protocol,
hostname: urlobj.hostname,
port: urlobj.port || (urlobj.protocol === "https:" ? 443 : 80),
path: urlobj.pathname + urlobj.search,
method: method || "GET",
headers: new_headers,
agent,
timeout: d_timeout,
rejectUnauthorized: false,
},
...options,
};
return new Promise((resolve, reject) => {
const req = protocol.request(options, async (res) => {
try {
const chunks = [];
for await (const chunk of res) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
// res = new Response(res); //新
// const body = await res.text();
// console.dev(res.status, res.statusText, res.ok);
resolve(
resbuild.bind(reqbd)(
true,
"http/1.1",
res.statusCode,
res.headers,
body
)
);
} catch (error) {
cerror.bind({ info: -1 })(error);
// reject(error);
resolve(resbuild.bind(reqbd)(false, "http/1.1"));
}
});
req.on("error", (error) => {
if (!error.message)
cerror.bind({ info: -1 })(error.message, "目标存在,当前协议不通");
// reject(error);
resolve(resbuild.bind(reqbd)(false, "http/1.1"));
});
req.on("timeout", () => {
// 此destroy后再触发onerror来传递错误
// req.destroy(
// new Error(`HTTP/1.1 req timeout >${options.timeout}ms`, urlobj.host)
// );
cerror.bind({ info: -1 })(
`HTTP/1.1 req timeout >${options.timeout}ms`,
urlobj.host
);
resolve(resbuild.bind(reqbd)(false, "http/1.1", 408));
req.destroy();
});
// req.on("socket", (socket) => {
// if (socket.connecting) {
// console.dev("创建h1连接");
// } else {
// console.dev("复用h1连接");
// }
// });
if (!empty(body)) req.write(body);
req.end();
});
}
// 有些不标准的返回,内容可能是json,但没ct或ct是text/plain,新方案是直接不管ct
// 目前就支持json和furl,可能还有yaml
function body2data(body, ct) {
// console.dev(body);
let data;
// if (!ct||ct.startsWith("application/json")) {
// try {
// data = JSON.parse(body);
// } catch {
// data = {};
// }
// } else if (ct === "application/x-www-form-urlencoded") {
// data = {};
// const params = new URLSearchParams(body);
// for (const [key, value] of params) {
// data[key] = value;
// }
// }
try {
data = JSON.parse(body);
} catch {
data = {};
const params = new URLSearchParams(body);
for (const [key, value] of params) {
data[key] = value;
}
if ((empty(data), 1)) data = body;
}
return data;
}
// cookie相关 键值数组,分号分割取第一个,跟现在的cookie相融
function setcookie(arr, str) {
if (arr) return str || "" + arr.map((item) => item.split(";")[0]).join("; ");
else return str || "";
}
// 自动解码br(73.34%高 14.673ms慢) deflate(65.83% 0.7ms快) zstd(66.46% 1.556ms) gzip(65.71% 3.624ms全面落后)
async function autoDecompressBody(body, ce) {
if (!body) return "";
try {
if (ce === "br") body = await br_decompress(body);
else if (ce === "deflate") body = await inflate(body);
else if (ce === "zstd") body = await zstd_decompress(body);
else if (ce === "gzip") body = await gunzip(body);
} catch (err) {
cerror.bind({ info: -1 })("返回数据解压失败", err);
}
return body.toString();
}
class Reqbd {
/** @type {any} */ h2session;
/** @type {URL} */ urlobj;
/** @type {string} */ url;
/** @type {string} */ method;
/** @type {Object} */ headers;
/** @type {string|Buffer} */ body;
/** @type {Object} */ options;
constructor(props = {}) {
Object.assign(this, props);
}
}
class Resbd {
/** @type {boolean} */ ok;
/** @type {number} */ code;
/** @type {Object} */ headers;
/** @type {string} */ cookie;
/** @type {string|Buffer} */ body;
/** @type {Object} */ data;
/** @type {string} */ protocol;
/** @type {Reqbd} */ reqbd;
/** @type {typeof req} */ req;
/** @type {typeof h1req} */ h1req;
/** @type {typeof h2req} */ h2req;
constructor(props = {}) {
Object.assign(this, props);
}
}
function reqbuild(...argv) {
try {
let props = this || {};
let {
h2session,
urlobj,
url,
method,
headers = {},
body = "",
options = {},
} = props;
if (argv.length === 0) {
if (empty(this)) throw new Error("首次构建,至少传入url");
else return this;
}
if (typeof argv[0] === "object") {
const {
h2session: newSession,
method: newMethod,
body: newBody,
headers: newHeaders,
options: newOptions,
url: newUrl,
} = argv[0];
h2session = newSession || h2session;
method = newMethod || method;
body = newBody || body;
headers = { ...headers, ...newHeaders };
options = { ...options, ...newOptions };
// 把新url复用思路处理一遍
argv = [newUrl];
}
let new_headers, new_options;
if (typeof argv[0] === "string") {
const arr = argv[0].replace(/ +/, " ").split(" ");
if (arr[0].startsWith("http")) {
url = arr[0];
method = arr[1] || method || "GET";
} else if (arr[0].startsWith("/")) {
url = urlobj.origin + arr[0];
method = arr[1] || method;
} else if (arr[0].startsWith("?")) {
url = urlobj.origin + urlobj.pathname + arr[0];
method = arr[1] || method;
} else {
if (empty(this)) throw new Error("构造错误,请参考文档或示例");
}
argv.slice(1).forEach((item) => {
if (
(!body &&
((typeof item === "string" && item !== "") ||
(() => {
if (typeof item !== "number") return false;
item = item.toString();
return true;
})() ||
item instanceof Buffer ||
ArrayBuffer.isView(item))) || // 也能直接接收
(() => {
if (item instanceof URLSearchParams) {
item = item.toString();
headers["content-type"] =
headers["content-type"] || "application/x-www-form-urlencoded";
return true;
} else return false;
})()
)
body = item;
else if (empty(item)) new_options = {};
else if (typeof item === "object") {
if (Object.keys(item).some((key) => options_keys.includes(key))) {
if (!new_options) new_options = item;
else if (!new_headers) new_headers = item;
} else {
if (!new_headers) new_headers = item;
}
}
});
}
method = method?.toUpperCase();
try {
urlobj = new URL(url);
} catch {
console.dev("url构造错误", url, "使用原urlobj");
}
headers = { ...headers, ...new_headers } || {};
options = { ...options, ...new_options } || {};
if (options) {
if ("cert" in options) {
options.rejectUnauthorized = options.cert;
delete options.cert;
}
if ("json" in options) {
headers["content-type"] = "application/json";
body = JSON.stringify(options.json);
delete options.json;
}
if ("furl" in options) {
headers["content-type"] = "application/x-www-form-urlencoded";
body =
typeof options.param === "string"
? options.param
: obj2furl(options.furl);
delete options.param;
}
if ("auth" in options) headers["authorization"] = options.auth;
if ("ua" in options) headers["user-agent"] = options.ua;
}
return new Reqbd({
h2session,
urlobj,
url,
method,
headers,
body,
options,
});
} catch (err) {
cerror.bind({ info: -1 })(err);
}
}
async function resbuild(ok, protocol, code, headers, body) {
ok = code >= 200 && code < 300 ? true : false;
const reqbd = this;
let cookie = setcookie(headers?.["set-cookie"], reqbd.headers.cookie);
if (cookie) reqbd.headers.cookie = cookie;
let data;
if (body) {
body = await autoDecompressBody(body, headers["content-encoding"]);
data = body2data(body, headers["content-type"]);
}
const res = new Resbd({
ok,
code,
headers,
cookie,
body,
data,
protocol,
reqbd,
});
res.req = async (...argv) => req(reqbuild.bind(reqbd)(...argv));
res.h1req = async (...argv) => h1req(reqbuild.bind(reqbd)(...argv));
res.h2req = async (...argv) => h2req(reqbuild.bind(reqbd)(...argv));
return Object.defineProperties(res, {
h2session: { enumerable: false, writable: false, configurable: false },
req: { enumerable: false, writable: false, configurable: false },
h1req: { enumerable: false, writable: false, configurable: false },
h2req: { enumerable: false, writable: false, configurable: false },
reqbd: { enumerable: false, writable: false, configurable: false },
reset: { enumerable: false, writable: false, configurable: false },
reset_org: { enumerable: false, writable: false, configurable: false },
reset_hds: { enumerable: false, writable: false, configurable: false },
reset_ops: { enumerable: false, writable: false, configurable: false },
});
}
async function myip() {
let res =
(await h1req("http://api.ipify.org", { timeout: 1500 })).body ||
(await h1req("http://ipv4.icanhazip.com", { timeout: 1500 })).body ||
(await h1req("http://v4.ident.me", { timeout: 1500 })).body ||
fn_myip();
return res.replace(/[^\d.]/g, ""); // 只保留数字和点
}
// 以下的公网私网推断还不错,留供参考
function fn_myip() {
const networkInterfaces = os.networkInterfaces();
let arr = [];
// 遍历所有网络接口
for (const interfaceName in networkInterfaces) {
const interfaces = networkInterfaces[interfaceName];
for (const infa of interfaces) {
// 过滤IPv4地址且不是内部地址 本地回环时 infa.internal=true
// 优先返回公网ip
if (infa.family === "IPv4" && !infa.internal) {
// console.log(`IP地址: ${infa.address}`);
if (
infa.address.startsWith("10.") || //A类私有 大型企业内网
infa.address.startsWith("192.168.") //C类私有 小型内网
)
arr.push(infa.address);
else if (infa.address.startsWith("172.")) {
//排除掉B类私有 虚拟机网络
const n = infa.address.split(".")[1];
if (n < 16 && n > 31) return infa.address;
} else return infa.address;
}
}
}
return arr.length > 0 ? arr[0] : "127.0.0.1";
}
/**
* 对象转form-urlencode 支持url/utf8编码 common/php/java风格(可拓展)
* 一维都一样,二维以上处理各有不同,默认common风格
* @param {*} obj
* @param {*} encoding
* @param {*} standard
* @returns
*/
function obj2furl(obj, encoding = "url", standard = "common") {
const encodeMap = {
url: function (str) {
return encodeURIComponent(str);
},
utf8: function (str) {
return str;
},
};
const standardMap = {
// 通用规范:user[name]=xxx&hobbies[0]=xxx
common: {
handleKey: function (parentKey, key) {
return parentKey ? parentKey + "[" + key + "]" : key;
},
handleArray: function (key, value, encode) {
return value
.map((item, index) => {
if (typeof item === "object" && item !== null) {
return serialize(item, `${key}[${index}]`);
}
return `${key}[${index}]=${encode(String(item))}`;
})
.join("&");
},
},
// PHP规范:user[name]=xxx&user[hobbies][]=xxx
php: {
handleKey: function (parentKey, key, value) {
const isArray = Array.isArray(value);
if (!parentKey) return key;
// 如果父级已经是数组,直接用key
if (parentKey.endsWith("[]")) {
return parentKey + "[" + key + "]";
}
// 处理数组
if (isArray) {
return parentKey + "[" + key + "][]";
}
return parentKey + "[" + key + "]";
},
handleArray: function (key, value, encode) {
return value
.map((item) => {
if (typeof item === "object" && item !== null) {
return serialize(item, key);
}
return `${key}=${encode(String(item))}`;
})
.join("&");
},
},
// Java规范:user.name=xxx&hobbies=xxx
java: {
handleKey: function (parentKey, key, value) {
if (!parentKey) return key;
// 处理数组对象的情况
if (parentKey.includes("[")) {
return parentKey + "." + key;
}
return parentKey + "." + key;
},
handleArray: function (key, value, encode) {
return value
.map((item, index) => {
if (typeof item === "object" && item !== null) {
// 对象数组使用索引
return serialize(item, `${key}[${index}]`);
}
// 简单数组使用重复键名
return `${key}=${encode(String(item))}`;
})
.join("&");
},
},
};
const encode = encodeMap[encoding] || encodeMap.url;
const formatter = standardMap[standard] || standardMap.common;
function serialize(obj, parentKey) {
const pairs = [];
for (const key in obj) {
if (!obj.hasOwnProperty(key)) {
continue;
}
const value = obj[key];
const currentKey = formatter.handleKey(parentKey, key, value);
if (value == null) {
pairs.push(currentKey + "=");
continue;
}
if (typeof value === "object") {
if (Array.isArray(value)) {
pairs.push(formatter.handleArray(currentKey, value, encode));
continue;
}
pairs.push(serialize(value, currentKey));
continue;
}
pairs.push(currentKey + "=" + encode(String(value)));
}
return pairs.join("&");
}
return serialize(obj, "");
}