ikun2 / http.js
bingn's picture
Upload 17 files
fd211b3 verified
/**
* http.js - HTTP 请求工具模块
* 纯 Node.js 原生 https/http 实现,零依赖
*/
import https from 'https';
import http from 'http';
import { URL } from 'url';
import config from './config.js';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/**
* 通用 HTTP 请求 — 支持 cookie 管理、重定向跟踪、JSON 自动解析
*/
export async function request(url, options = {}) {
const {
method = 'GET',
headers = {},
body = null,
timeout = config.timeout || 30000,
followRedirect = true,
maxRedirects = 5,
cookies = null,
} = options;
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const lib = isHttps ? https : http;
const finalHeaders = {
'User-Agent': config.userAgent,
'Accept': 'application/json, text/html, */*',
'Accept-Language': 'en-US,en;q=0.9,ru;q=0.8,zh-CN;q=0.7',
'Accept-Encoding': 'identity',
...headers,
};
// 注入 cookies
if (cookies && cookies.size > 0) {
finalHeaders['Cookie'] = [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
// 处理 body
let bodyStr = null;
if (body) {
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
if (!finalHeaders['Content-Type']) {
finalHeaders['Content-Type'] = 'application/json';
}
finalHeaders['Content-Length'] = Buffer.byteLength(bodyStr);
}
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method,
headers: finalHeaders,
timeout,
};
const req = lib.request(reqOptions, (res) => {
// 收集 Set-Cookie
const resCookies = new Map(cookies || []);
const setCookieHeaders = res.headers['set-cookie'] || [];
for (const sc of setCookieHeaders) {
const [pair] = sc.split(';');
const [name, ...valueParts] = pair.split('=');
resCookies.set(name.trim(), valueParts.join('=').trim());
}
// 处理重定向
if (followRedirect && [301, 302, 303, 307, 308].includes(res.statusCode) && maxRedirects > 0) {
const location = res.headers['location'];
if (location) {
const redirectUrl = location.startsWith('http') ? location : new URL(location, url).href;
res.resume(); // 丢弃 body
resolve(request(redirectUrl, {
...options,
maxRedirects: maxRedirects - 1,
cookies: resCookies,
method: [301, 302, 303].includes(res.statusCode) ? 'GET' : method,
body: [301, 302, 303].includes(res.statusCode) ? null : body,
}));
return;
}
}
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
statusText: res.statusMessage,
headers: res.headers,
cookies: resCookies,
url,
text: () => data,
json: () => {
try { return JSON.parse(data); }
catch { throw new Error(`JSON parse error: ${data.substring(0, 200)}`); }
},
html: () => data,
});
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error(`请求超时: ${url}`)); });
if (bodyStr) req.write(bodyStr);
req.end();
});
}
/**
* GET 请求简写
*/
export async function get(url, options = {}) {
return request(url, { ...options, method: 'GET' });
}
/**
* POST 请求简写
*/
export async function post(url, body, options = {}) {
return request(url, { ...options, method: 'POST', body });
}
/**
* 带重试的请求
*/
export async function retryRequest(url, options = {}, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
const resp = await request(url, options);
if (resp.ok || resp.status < 500) return resp;
lastError = new Error(`HTTP ${resp.status}: ${resp.text().substring(0, 200)}`);
} catch (e) {
lastError = e;
}
if (i < maxRetries - 1) {
const delay = 1000 * (i + 1) + Math.random() * 1000;
console.log(` [HTTP] 重试 ${i + 1}/${maxRetries} (${Math.round(delay)}ms)...`);
await sleep(delay);
}
}
throw lastError;
}
export { sleep };
/**
* 流式 HTTP 请求 — 返回原始响应流 (不缓冲 body)
* 用于代理 chataibot SSE 流式响应
*/
export async function requestStream(url, options = {}) {
const {
method = 'GET',
headers = {},
body = null,
timeout = config.timeout || 120000,
cookies = null,
} = options;
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const lib = isHttps ? https : http;
const finalHeaders = {
'User-Agent': config.userAgent,
'Accept': 'text/event-stream, application/json',
'Accept-Language': 'en',
'Accept-Encoding': 'identity',
...headers,
};
if (cookies && cookies.size > 0) {
finalHeaders['Cookie'] = [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
let bodyStr = null;
if (body) {
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
if (!finalHeaders['Content-Type']) {
finalHeaders['Content-Type'] = 'application/json';
}
finalHeaders['Content-Length'] = Buffer.byteLength(bodyStr);
}
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method,
headers: finalHeaders,
timeout,
};
const req = lib.request(reqOptions, (res) => {
const resCookies = new Map(cookies || []);
const setCookieHeaders = res.headers['set-cookie'] || [];
for (const sc of setCookieHeaders) {
const [pair] = sc.split(';');
const [name, ...valueParts] = pair.split('=');
resCookies.set(name.trim(), valueParts.join('=').trim());
}
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
headers: res.headers,
cookies: resCookies,
stream: res,
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error(`流式请求超时: ${url}`)); });
if (bodyStr) req.write(bodyStr);
req.end();
});
}