diff --git a/core/http_client.js b/core/http_client.js index d690a38..b46a2ab 100644 --- a/core/http_client.js +++ b/core/http_client.js @@ -7,16 +7,17 @@ // Подключение внешних библиотек //------------------------------ -const http = require("http"); -const https = require("https"); -const { URL } = require("url"); -const { Socket } = require("net"); +const http = require("http"); //Встроенная поддержка HTTP +const https = require("https"); //Встроенная поддержка HTTPS +const { URL } = require("url"); //Обслуживание типовых URL +const { Socket } = require("net"); //Встроенная поддержка сокетов -//-------------------------- +//------------------------- // Локальные идентификаторы -//-------------------------- +//------------------------- -const DEFAULT_TIMEOUT = 30000; //Таймаут по умолчанию +//Таймаут по умолчанию +const DEFAULT_TIMEOUT = 30000; //Ошибка HTTP-запроса class HttpError extends Error { @@ -27,491 +28,25 @@ class HttpError extends Error { } } -//Обработка ответа (парсинг JSON, форматирование для совместимости с request-promise) -const processResponse = (result, options) => { - let processedBody = result.body; +//------------------------ +// Вспомогательные функции +//------------------------ - //Если запрошен автоматический парсинг JSON - if (options.json === true && processedBody) { - try { - const text = processedBody.toString(options.encoding || "utf8"); - processedBody = text ? JSON.parse(text) : null; - } catch (e) { - //Если не удалось распарсить JSON, возвращаем как есть - processedBody = processedBody.toString(options.encoding || "utf8"); - } - } else if (options.encoding && processedBody) { - //Если указана кодировка, конвертируем в строку - processedBody = processedBody.toString(options.encoding); - } - - //Формируем результат в зависимости от resolveWithFullResponse - if (options.resolveWithFullResponse) { - return { - statusCode: result.statusCode, - statusMessage: result.statusMessage || "", - headers: result.headers, - body: processedBody, - url: result.url - }; - } else { - //Если json: true, возвращаем распарсенный объект, иначе Buffer/string - return processedBody; - } -}; - -//Выполнение HTTP/HTTPS запроса через встроенные модули (для прокси и TLS) -const httpRequestNative = (options, url, headers, body) => { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const isHttps = urlObj.protocol === "https:"; - const httpModule = isHttps ? https : http; - - //Настройки TLS - const tlsOptions = {}; - if (options.ca) { - tlsOptions.ca = Array.isArray(options.ca) ? options.ca : [options.ca]; - } - if (options.cert) { - tlsOptions.cert = options.cert; - } - if (options.key) { - tlsOptions.key = options.key; - } - if (options.passphrase) { - tlsOptions.passphrase = options.passphrase; - } - if (options.rejectUnauthorized !== undefined) { - tlsOptions.rejectUnauthorized = options.rejectUnauthorized; - } - - //Настройки прокси - let proxyUrl = null; - let proxyAuth = null; - if (options.proxy) { - try { - proxyUrl = new URL(options.proxy); - if (proxyUrl.username || proxyUrl.password) { - proxyAuth = `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username || "")}:${decodeURIComponent(proxyUrl.password || "")}`).toString("base64")}`; - } - } catch (e) { - return reject(new Error(`Некорректный URL прокси: ${e.message}`)); - } - } - - //Параметры запроса - const requestOptions = { - hostname: proxyUrl ? proxyUrl.hostname : urlObj.hostname, - port: proxyUrl ? proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80) : urlObj.port || (isHttps ? 443 : 80), - path: proxyUrl ? url : urlObj.pathname + urlObj.search, - method: options.method, - headers: Object.fromEntries(headers), - timeout: options.timeout || DEFAULT_TIMEOUT - }; - - //Добавляем авторизацию прокси в заголовки - if (proxyAuth) { - requestOptions.headers["Proxy-Authorization"] = proxyAuth; - } - - //Для HTTPS добавляем TLS опции - if (isHttps && Object.keys(tlsOptions).length > 0) { - Object.assign(requestOptions, tlsOptions); - } - - //Если используется прокси, нужно использовать CONNECT метод для HTTPS - if (proxyUrl && isHttps) { - //Для HTTPS через прокси используем туннелирование - return httpRequestThroughProxy(requestOptions, urlObj, headers, body, proxyUrl, proxyAuth, tlsOptions, options) - .then(result => resolve(processResponse(result, options))) - .catch(reject); - } - - //Обычный запрос (с прокси для HTTP или без прокси) - const req = httpModule.request(requestOptions, res => { - const chunks = []; - res.on("data", chunk => { - chunks.push(chunk); - }); - res.on("end", () => { - const responseBody = Buffer.concat(chunks); - const result = { - statusCode: res.statusCode, - statusMessage: res.statusMessage || "", - headers: res.headers, - body: responseBody, - ok: res.statusCode >= 200 && res.statusCode < 300, - url: url - }; - - if (options.throwOnErrorStatus && !result.ok) { - const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result); - const httpError = new Error(error.message); - httpError.response = result; - httpError.statusCode = result.statusCode; - return reject(httpError); - } - - resolve(processResponse(result, options)); - }); - }); - - req.on("error", err => { - const error = new Error(err.message); - error.code = err.code; - error.error = { code: err.code }; - reject(error); - }); - - req.on("timeout", () => { - req.destroy(); - const timeoutError = new Error(`Время ожидания выполнения запроса истекло после ${options.timeout || DEFAULT_TIMEOUT} мс`); - timeoutError.code = "ETIMEDOUT"; - timeoutError.error = { code: "ETIMEDOUT" }; - reject(timeoutError); - }); - - //Отправляем тело запроса - if (body) { - if (Buffer.isBuffer(body)) { - req.write(body); - } else if (typeof body === "string") { - req.write(Buffer.from(body)); - } else { - req.write(Buffer.from(JSON.stringify(body))); - } - } - - req.end(); - }); -}; - -//Выполнение HTTPS запроса через HTTP прокси (туннелирование) -const httpRequestThroughProxy = (requestOptions, targetUrl, headers, body, proxyUrl, proxyAuth, tlsOptions, options) => { - const timeout = options.timeout || DEFAULT_TIMEOUT; - return new Promise((resolve, reject) => { - const proxyHost = proxyUrl.hostname; - const proxyPort = parseInt(proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80), 10); - const targetHost = targetUrl.hostname; - const targetPort = parseInt(targetUrl.port || 443, 10); - - //Создаем соединение с прокси - const proxySocket = new Socket(); - let connected = false; - let requestSent = false; - - const connectTimeout = setTimeout(() => { - if (!connected || !requestSent) { - proxySocket.destroy(); - reject(new Error(`Время ожидания подключения к прокси истекло после ${timeout} мс`)); - } - }, timeout); - - proxySocket.setTimeout(timeout); - proxySocket.on("timeout", () => { - proxySocket.destroy(); - reject(new Error(`Время ожидания выполнения запроса истекло после ${timeout} мс`)); - }); - - proxySocket.connect(proxyPort, proxyHost, () => { - connected = true; - - //Отправляем CONNECT запрос - const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`; - const authHeader = proxyAuth ? `Proxy-Authorization: ${proxyAuth}\r\n` : ""; - const connectHeaders = connectRequest + authHeader + "\r\n"; - - proxySocket.write(connectHeaders); - - let connectResponse = ""; - const onConnectData = chunk => { - connectResponse += chunk.toString(); - const headerEnd = connectResponse.indexOf("\r\n\r\n"); - if (headerEnd !== -1) { - proxySocket.removeListener("data", onConnectData); - clearTimeout(connectTimeout); - - //Проверяем ответ на CONNECT - const statusLine = connectResponse.substring(0, connectResponse.indexOf("\r\n")); - if (statusLine.includes("200")) { - //Успешное подключение, создаем TLS соединение - const tls = require("tls"); - const secureSocket = tls.connect( - { - socket: proxySocket, - host: targetHost, - servername: targetHost, - ...tlsOptions - }, - () => { - requestSent = true; - clearTimeout(connectTimeout); - - //Отправляем HTTP запрос через TLS - const path = targetUrl.pathname + targetUrl.search; - const requestLine = `${requestOptions.method} ${path} HTTP/1.1\r\n`; - const hostHeader = `Host: ${targetHost}${targetPort !== 443 ? `:${targetPort}` : ""}\r\n`; - const requestHeaders = Array.from(headers.entries()) - .map(([key, value]) => `${key}: ${value}\r\n`) - .join(""); - const httpRequest = requestLine + hostHeader + requestHeaders + "\r\n"; - - secureSocket.write(httpRequest); - - if (body) { - const bodyBuffer = Buffer.isBuffer(body) - ? body - : typeof body === "string" - ? Buffer.from(body) - : Buffer.from(JSON.stringify(body)); - secureSocket.write(bodyBuffer); - } - - //Читаем ответ - let responseBuffer = Buffer.alloc(0); - let responseHeaders = {}; - let statusCode = 200; - let headerParsed = false; - let contentLength = -1; - - const onSecureData = chunk => { - responseBuffer = Buffer.concat([responseBuffer, chunk]); - - if (!headerParsed) { - const headerEnd = responseBuffer.indexOf("\r\n\r\n"); - if (headerEnd !== -1) { - const headerText = responseBuffer.subarray(0, headerEnd).toString(); - const bodyStart = headerEnd + 4; - responseBuffer = responseBuffer.subarray(bodyStart); - - //Парсим заголовки - const lines = headerText.split("\r\n"); - const statusLine = lines[0]; - const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)/); - if (statusMatch) { - statusCode = parseInt(statusMatch[1], 10); - } - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - const colonIndex = line.indexOf(":"); - if (colonIndex !== -1) { - const key = line.substring(0, colonIndex).trim().toLowerCase(); - const value = line.substring(colonIndex + 1).trim(); - responseHeaders[key] = value; - if (key === "content-length") { - contentLength = parseInt(value, 10); - } - } - } - - headerParsed = true; - - //Если есть Content-Length и мы уже получили все данные - if (contentLength >= 0 && responseBuffer.length >= contentLength) { - secureSocket.removeListener("data", onSecureData); - secureSocket.end(); - } - } - } else { - //Проверяем, получили ли мы все данные - if (contentLength >= 0 && responseBuffer.length >= contentLength) { - secureSocket.removeListener("data", onSecureData); - secureSocket.end(); - } else if (contentLength < 0) { - //Нет Content-Length, читаем до конца - //Будем ждать закрытия соединения - } - } - }; - - secureSocket.on("data", onSecureData); - - secureSocket.on("end", () => { - const result = { - statusCode: statusCode, - statusMessage: "", - headers: responseHeaders, - body: responseBuffer, - ok: statusCode >= 200 && statusCode < 300, - url: targetUrl.toString() - }; - - if (options.throwOnErrorStatus && !result.ok) { - const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result); - const httpError = new Error(error.message); - httpError.response = result; - httpError.statusCode = result.statusCode; - return reject(httpError); - } - - resolve(processResponse(result, options)); - }); - - secureSocket.on("error", err => { - reject(err); - }); - } - ); - - secureSocket.on("error", err => { - reject(err); - }); - } else { - proxySocket.destroy(); - reject(new Error(`Прокси вернул ошибку: ${statusLine}`)); - } - } - }; - - proxySocket.on("data", onConnectData); - }); - - proxySocket.on("error", err => { - clearTimeout(connectTimeout); - reject(err); - }); - }); -}; - -//Выполнение HTTP/HTTPS запроса -const httpRequest = async (rawOptions = {}) => { - try { - //Нормализуем параметры запроса - const options = normalizeOptions(rawOptions); - //Сформируем ссылку - const url = buildURL(options.url, options.query); - //Подготовим заголовки и тело запроса - const headers = prepareHeaders(options.headers); - const { body, contentLength, isStream } = prepareBody({ - body: options.body, - jsonRequest: options.jsonRequest, - headers - }); - //Если не указан размер тела - if (contentLength !== undefined && !headers.has("content-length")) { - //Установим размер тела в заголовок - headers.set("content-length", String(contentLength)); - } - - //Если есть поток, читаем его - let requestBody = body; - if (isStream && body && typeof body.pipe === "function") { - const chunks = []; - try { - for await (const chunk of body) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - requestBody = chunks.length > 0 ? Buffer.concat(chunks) : undefined; - } catch (streamError) { - throw new Error(`Ошибка чтения потока: ${streamError.message}`); - } - } - - //Если нужен прокси или специальные TLS настройки, используем встроенные модули - if (options.proxy || options.ca || options.cert || options.key || options.passphrase || options.rejectUnauthorized === false) { - return await httpRequestNative(options, url.toString(), headers, requestBody); - } - - //Для простых запросов используем встроенный fetch - const fetchImpl = globalThis.fetch; - if (typeof fetchImpl !== "function") { - throw new Error("globalThis.fetch недоступен. Используйте Node.js >= 18."); - } - - const timeoutController = new AbortController(); - const signals = []; - - if (options.signal && options.signal instanceof AbortSignal) { - signals.push(options.signal); - } - signals.push(timeoutController.signal); - - const combinedSignal = AbortSignal.any(signals); - - const timeoutId = - options.timeout > 0 - ? setTimeout( - () => timeoutController.abort(new Error(`Время ожидания выполнения запроса истекло после ${options.timeout} мс`)), - options.timeout - ) - : null; - - try { - const fetchOptions = { - method: options.method, - headers: Object.fromEntries(headers), - body: requestBody, - signal: combinedSignal, - redirect: options.followRedirects ? "follow" : "manual" - }; - const response = await fetchImpl(url.toString(), fetchOptions); - const responseBody = Buffer.from(await response.arrayBuffer()); - const result = { - statusCode: response.status, - statusMessage: response.statusText || "", - headers: Object.fromEntries(response.headers.entries()), - body: responseBody, - ok: response.ok, - url: response.url - }; - - if (options.throwOnErrorStatus && !result.ok) { - const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result); - const httpError = new Error(error.message); - httpError.response = result; - httpError.statusCode = result.statusCode; - throw httpError; - } - - return processResponse(result, options); - } finally { - if (timeoutId) clearTimeout(timeoutId); - } - } catch (e) { - //Преобразуем ошибки для совместимости с request-promise - if (e.name === "AbortError" || e.message.includes("время ожидания")) { - const timeoutError = new Error(e.message); - timeoutError.code = "ETIMEDOUT"; - timeoutError.error = { code: "ETIMEDOUT" }; - throw timeoutError; - } - //Если ошибка уже имеет response (из httpRequestNative или fetch), пробрасываем как есть - if (e.response) { - throw e; - } - if (e instanceof HttpError) { - const httpError = new Error(e.message); - httpError.response = e.response; - httpError.statusCode = e.response.statusCode; - throw httpError; - } - //Для других ошибок добавляем поле error для совместимости - if (e.code) { - e.error = { code: e.code }; - } - throw e; - } -}; - -//Нормализация параметров запроса +//Нормализация параметров запроса (для совместимости с ранее применяемыми библиотеками "Request" и "Request-Promice") const normalizeOptions = options => { + //Проверим, что нам предлагают обработать объект if (!options || typeof options !== "object") { - throw new TypeError("options должен быть объектом"); + throw new TypeError("Параметры HTTP-запроса должны быть объектом"); } - //Поддержка параметра uri (как в request-promise) const url = options.uri || options.url; if (!url) throw new Error("options.url или options.uri обязателен"); - - //Обработаем JSON + //Обработаем параметр "JSON" const hasJsonOption = Object.prototype.hasOwnProperty.call(options, "json"); const jsonOption = options.json; - let body = options.body; let jsonRequest = false; let json = false; - if (hasJsonOption) { if (jsonOption === true) { json = true; @@ -523,14 +58,13 @@ const normalizeOptions = options => { json = true; } } - //Определяем HTTP-метод let method = options.method; if (!method) { const hasRequestBody = body != null; method = hasRequestBody ? "POST" : "GET"; } - + //Собираем все уже отконвертированные параметры вместе + конвертируем то, что не требует выноса логики return { method: String(method).toUpperCase(), url, @@ -576,39 +110,532 @@ const prepareHeaders = (inputHeaders = {}) => { //Подготовка тела запроса const prepareBody = ({ body, jsonRequest, headers }) => { + //Тела нет if (body === undefined || body === null) { return { body: undefined, contentLength: undefined, isStream: false }; } - //Явно указано, что тело нужно сериализовать как JSON if (jsonRequest) { const payload = Buffer.from(JSON.stringify(body)); if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8"); return { body: payload, contentLength: payload.length, isStream: false }; } - + //Тело - бинарные данные if (Buffer.isBuffer(body) || body instanceof Uint8Array) { return { body, contentLength: body.length, isStream: false }; } + //Тело - строка if (typeof body === "string") { const payload = Buffer.from(body); return { body: payload, contentLength: payload.length, isStream: false }; } + //Тело - поток if (typeof body.pipe === "function") { return { body, contentLength: undefined, isStream: true }; } - //По умолчанию объектное тело сериализуем в JSON if (typeof body === "object") { const payload = Buffer.from(JSON.stringify(body)); if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8"); return { body: payload, contentLength: payload.length, isStream: false }; } - //Фолбэк для прочих типов const payload = Buffer.from(String(body)); return { body: payload, contentLength: payload.length, isStream: false }; }; -exports.httpRequest = httpRequest; +//Обработка ответа (парсинг JSON, форматирование для совместимости с "Request" и "Request-Promice") +const processResponse = (result, options) => { + //Буфер для обработки тела + let processedBody = result.body; + //Если запрошен автоматический парсинг JSON + if (options.json === true && processedBody) { + try { + const text = processedBody.toString(options.encoding || "utf8"); + processedBody = text ? JSON.parse(text) : null; + } catch (e) { + //Если не удалось распарсить JSON, возвращаем как есть + processedBody = processedBody.toString(options.encoding || "utf8"); + } + } else if (options.encoding && processedBody) { + //Если указана кодировка, конвертируем в строку + processedBody = processedBody.toString(options.encoding); + } + //Формируем результат в зависимости от "resolveWithFullResponse" (это параметр из "Request": true - отвечать с заголовком, false - отвечать сразу телом) + if (options.resolveWithFullResponse) { + //Просили ответить полным ответом + return { + statusCode: result.statusCode, + statusMessage: result.statusMessage || "", + headers: result.headers, + body: processedBody, + url: result.url + }; + } else { + //Просили только тело + return processedBody; + } +}; + +//Выполнение HTTP/HTTPS запроса через встроенные модули NodeJS (для прокси и TLS) +const httpRequestNative = (options, url, headers, body) => { + //Возвращаем промис - запрос исполняется асинхронно + return new Promise((resolve, reject) => { + //Определяемся с адресом + const urlObj = new URL(url); + //И модулем, обслуживающим протокол + const isHttps = urlObj.protocol === "https:"; + const httpModule = isHttps ? https : http; + //Настройки TLS + const tlsOptions = {}; + if (options.ca) { + tlsOptions.ca = Array.isArray(options.ca) ? options.ca : [options.ca]; + } + if (options.cert) { + tlsOptions.cert = options.cert; + } + if (options.key) { + tlsOptions.key = options.key; + } + if (options.passphrase) { + tlsOptions.passphrase = options.passphrase; + } + if (options.rejectUnauthorized !== undefined) { + tlsOptions.rejectUnauthorized = options.rejectUnauthorized; + } + //Настройки прокси + let proxyUrl = null; + let proxyAuth = null; + if (options.proxy) { + try { + proxyUrl = new URL(options.proxy); + if (proxyUrl.username || proxyUrl.password) { + proxyAuth = `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username || "")}:${decodeURIComponent(proxyUrl.password || "")}`).toString("base64")}`; + } + } catch (e) { + return reject(new Error(`Некорректный URL прокси: ${e.message}`)); + } + } + //Параметры запроса + const requestOptions = { + hostname: proxyUrl ? proxyUrl.hostname : urlObj.hostname, + port: proxyUrl ? proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80) : urlObj.port || (isHttps ? 443 : 80), + path: proxyUrl ? url : urlObj.pathname + urlObj.search, + method: options.method, + headers: Object.fromEntries(headers), + timeout: options.timeout || DEFAULT_TIMEOUT + }; + //Добавляем авторизацию прокси в HTTP-заголовки + if (proxyAuth) { + requestOptions.headers["Proxy-Authorization"] = proxyAuth; + } + //Для HTTPS добавляем TLS опции + if (isHttps && Object.keys(tlsOptions).length > 0) { + Object.assign(requestOptions, tlsOptions); + } + //Если используется прокси, нужно использовать CONNECT метод для HTTPS + if (proxyUrl && isHttps) { + //Для HTTPS через прокси используем туннелирование + return httpRequestThroughProxy(requestOptions, urlObj, headers, body, proxyUrl, proxyAuth, tlsOptions, options) + .then(result => resolve(processResponse(result, options))) + .catch(reject); + } + //Обычный запрос (с прокси для HTTP или без прокси) + const req = httpModule.request(requestOptions, res => { + //Буфер для данных ответа + const chunks = []; + //При получении ответа + res.on("data", chunk => { + //Наполняем буфер данными от удаленного сервера + chunks.push(chunk); + }); + //При получении признака завершения обмена + res.on("end", () => { + //Собираем тело как бинарный буфер + const responseBody = Buffer.concat(chunks); + //Формируем объект ответа в стиле "Request" для совметимости + const result = { + statusCode: res.statusCode, + statusMessage: res.statusMessage || "", + headers: res.headers, + body: responseBody, + ok: res.statusCode >= 200 && res.statusCode < 300, + url: url + }; + //Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней + if (options.throwOnErrorStatus && !result.ok) { + const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result); + const httpError = new Error(error.message); + httpError.response = result; + httpError.statusCode = result.statusCode; + return reject(httpError); + } + //Разрешаем промис с данными ответа + resolve(processResponse(result, options)); + }); + }); + //При получении сетевых ошибок + req.on("error", err => { + const error = new Error(err.message); + error.code = err.code; + error.error = { code: err.code }; + reject(error); + }); + //При нарушении времени, выделенного на выполнения запроса + req.on("timeout", () => { + req.destroy(); + const timeoutError = new Error(`Время ожидания выполнения запроса истекло после ${options.timeout || DEFAULT_TIMEOUT} мс`); + timeoutError.code = "ETIMEDOUT"; + timeoutError.error = { code: "ETIMEDOUT" }; + reject(timeoutError); + }); + //Отправляем тело запроса + if (body) { + if (Buffer.isBuffer(body)) { + req.write(body); + } else if (typeof body === "string") { + req.write(Buffer.from(body)); + } else { + req.write(Buffer.from(JSON.stringify(body))); + } + } + //Закрываем запрос и начинаем ждать ответ + req.end(); + }); +}; + +//Выполнение HTTPS запроса через HTTP прокси (туннелирование) +const httpRequestThroughProxy = (requestOptions, targetUrl, headers, body, proxyUrl, proxyAuth, tlsOptions, options) => { + //Выставим предельное время исполнения запроса + const timeout = options.timeout || DEFAULT_TIMEOUT; + //Запрос всё ещё асинхронный - возвращаем промис + return new Promise((resolve, reject) => { + //Начальная точка туннеля + const proxyHost = proxyUrl.hostname; + const proxyPort = parseInt(proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80), 10); + //Конечная точка туннеля + const targetHost = targetUrl.hostname; + const targetPort = parseInt(targetUrl.port || 443, 10); + //Создаем соединение с прокси и флаги для отслеживания его состояния (подключено, запрос отправлен) + const proxySocket = new Socket(); + let connected = false; + let requestSent = false; + //Взводим таймер ожидания подключения для подчистки сокета, чтобы не было утечек памяти + const connectTimeout = setTimeout(() => { + if (!connected || !requestSent) { + proxySocket.destroy(); + reject(new Error(`Время ожидания подключения к прокси истекло после ${timeout} мс`)); + } + }, timeout); + //Настраиваем встроенный таймаут сокета + proxySocket.setTimeout(timeout); + proxySocket.on("timeout", () => { + proxySocket.destroy(); + reject(new Error(`Время ожидания выполнения запроса истекло после ${timeout} мс`)); + }); + //При подключении + proxySocket.connect(proxyPort, proxyHost, () => { + //Поднимаем флаг подключенности + connected = true; + //Настраиваем CONNECT-запрос + const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`; + const authHeader = proxyAuth ? `Proxy-Authorization: ${proxyAuth}\r\n` : ""; + const connectHeaders = connectRequest + authHeader + "\r\n"; + //Отправляем CONNECT-запрос + proxySocket.write(connectHeaders); + //Буфер для данных ответа на CONNECT-запрос + let connectResponse = ""; + //Функция для наполнения буфера ответа на CONNECT-запрос и его обработки + const onConnectData = chunk => { + //Добавляем в буфер очередную порцию данных, полученных от удаленного сервера + connectResponse += chunk.toString(); + //Вычислим значение флага завершения заголовка и начала тела ответа (в HTTP они отделяются последовательностью "\r\n\r\n") + const headerEnd = connectResponse.indexOf("\r\n\r\n"); + //Если был достигнут конец заголовка + if (headerEnd !== -1) { + //Прекращаем прием данных ответа на CONNECT-запрос + proxySocket.removeListener("data", onConnectData); + clearTimeout(connectTimeout); + //Проверяем ответ на CONNECT-запрос + const statusLine = connectResponse.substring(0, connectResponse.indexOf("\r\n")); + if (statusLine.includes("200")) { + //Успешное подключение, создаем TLS соединение + const tls = require("tls"); + const secureSocket = tls.connect( + { + socket: proxySocket, + host: targetHost, + servername: targetHost, + ...tlsOptions + }, + () => { + //Поднимаем флаг отправки запроса + requestSent = true; + clearTimeout(connectTimeout); + //Подготавливаем HTTP-запрос + const path = targetUrl.pathname + targetUrl.search; + const requestLine = `${requestOptions.method} ${path} HTTP/1.1\r\n`; + const hostHeader = `Host: ${targetHost}${targetPort !== 443 ? `:${targetPort}` : ""}\r\n`; + const requestHeaders = Array.from(headers.entries()) + .map(([key, value]) => `${key}: ${value}\r\n`) + .join(""); + const httpRequest = requestLine + hostHeader + requestHeaders + "\r\n"; + //Отправляем HTTP-запрос через TLS + secureSocket.write(httpRequest); + if (body) { + const bodyBuffer = Buffer.isBuffer(body) + ? body + : typeof body === "string" + ? Buffer.from(body) + : Buffer.from(JSON.stringify(body)); + secureSocket.write(bodyBuffer); + } + //Готовим буфер для чтения ответа + let responseBuffer = Buffer.alloc(0); + let responseHeaders = {}; + let statusCode = 200; + let headerParsed = false; + let contentLength = -1; + //Функция для наполнения и обработки буфера ответа + const onSecureData = chunk => { + //Добавляем очередную порцию к данным буфера ответа + responseBuffer = Buffer.concat([responseBuffer, chunk]); + //Если ещё не поднят флаг того, что разобрали заголовок + if (!headerParsed) { + //Найдем в буфере метку конца заголовка + const headerEnd = responseBuffer.indexOf("\r\n\r\n"); + //Метка конца заголовка найдена + if (headerEnd !== -1) { + //Фиксируем текст заголовка от начала буфера до отметки конца заголовка + const headerText = responseBuffer.subarray(0, headerEnd).toString(); + //Остальное - будет телом + const bodyStart = headerEnd + 4; + responseBuffer = responseBuffer.subarray(bodyStart); + //Разбираем полученный текст заголовка + const lines = headerText.split("\r\n"); + const statusLine = lines[0]; + const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)/); + if (statusMatch) { + statusCode = parseInt(statusMatch[1], 10); + } + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const colonIndex = line.indexOf(":"); + if (colonIndex !== -1) { + const key = line.substring(0, colonIndex).trim().toLowerCase(); + const value = line.substring(colonIndex + 1).trim(); + responseHeaders[key] = value; + if (key === "content-length") { + contentLength = parseInt(value, 10); + } + } + } + //И поднимаем флаг его разобранности + headerParsed = true; + //Если есть Content-Length и мы уже получили все данные + if (contentLength >= 0 && responseBuffer.length >= contentLength) { + //Прекращаем прием данных в буфер + secureSocket.removeListener("data", onSecureData); + //Закрываем TCP-соединение + secureSocket.end(); + } + } + } else { + //Заголовок уже прочитан, но данные ещё могут приходить - проверяем, получили ли мы все данные + if (contentLength >= 0 && responseBuffer.length >= contentLength) { + //Прекращаем прием данных в буфер + secureSocket.removeListener("data", onSecureData); + //Закрываем TCP-соединение + secureSocket.end(); + } else if (contentLength < 0) { + //Нет Content-Length, читаем до конца + //Будем ждать закрытия соединения со стороны хоста + } + } + }; + //Подключаем определенную выше функцию обработки данных к событию их получения + secureSocket.on("data", onSecureData); + //Слушаем завершение соединения и реагируем на него + secureSocket.on("end", () => { + //Формируем объект с результатами обработки соединения, совместимый с ожиданиями сервера приложений + const result = { + statusCode: statusCode, + statusMessage: "", + headers: responseHeaders, + body: responseBuffer, + ok: statusCode >= 200 && statusCode < 300, + url: targetUrl.toString() + }; + //Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней + if (options.throwOnErrorStatus && !result.ok) { + const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result); + const httpError = new Error(error.message); + httpError.response = result; + httpError.statusCode = result.statusCode; + return reject(httpError); + } + //Разрешаем промис предварительно сформировав ответ, совместимый со старыми библиотекаим "Request" + resolve(processResponse(result, options)); + }); + //Слушаем сетевые ошибки на защищенном соединении + secureSocket.on("error", err => { + reject(err); + }); + } + ); + //Слушаем сетевые ошибки на защищенном соединении + secureSocket.on("error", err => { + reject(err); + }); + } else { + //Ответ на CONNECT-запрос содержит статус, отличный от 200 - отключаемся, не прошли прокси + proxySocket.destroy(); + reject(new Error(`Прокси вернул ошибку: ${statusLine}`)); + } + } + }; + //Подключаем функцию обработки данных CONNECT-запроса и слушаем их + proxySocket.on("data", onConnectData); + }); + //Слушаем сетевые ошибки при выполнении CONNECT-запроса + proxySocket.on("error", err => { + clearTimeout(connectTimeout); + reject(err); + }); + }); +}; + +//------------ +// Тело модуля +//------------ + +//Выполнение HTTP/HTTPS запроса +const httpRequest = async (rawOptions = {}) => { + try { + //Нормализуем параметры запроса + const options = normalizeOptions(rawOptions); + //Сформируем ссылку + const url = buildURL(options.url, options.query); + //Подготовим заголовки и тело запроса + const headers = prepareHeaders(options.headers); + const { body, contentLength, isStream } = prepareBody({ + body: options.body, + jsonRequest: options.jsonRequest, + headers + }); + //Если не указан размер тела + if (contentLength !== undefined && !headers.has("content-length")) { + //Установим размер тела в заголовок + headers.set("content-length", String(contentLength)); + } + //Если есть поток, читаем его + let requestBody = body; + if (isStream && body && typeof body.pipe === "function") { + const chunks = []; + try { + for await (const chunk of body) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + requestBody = chunks.length > 0 ? Buffer.concat(chunks) : undefined; + } catch (streamError) { + throw new Error(`Ошибка чтения потока: ${streamError.message}`); + } + } + //Если нужен прокси или специальные TLS настройки, используем встроенные модули + if (options.proxy || options.ca || options.cert || options.key || options.passphrase || options.rejectUnauthorized === false) { + return await httpRequestNative(options, url.toString(), headers, requestBody); + } + //Для простых запросов используем встроенный fetch + const fetchImpl = globalThis.fetch; + if (typeof fetchImpl !== "function") { + throw new Error('Среда исполнения не поддерживает "fetch", используйте NodeJS >= 22.21.1.'); + } + //Добавим к нему аборт-контроллер для управления отменой запроса по таймауту + const timeoutController = new AbortController(); + const signals = []; + //Один - на базе параметра "nTimeoutAsynch", получаемого из "Функции сервиса обмена", атрибут "Таймаут асинхронной отправки" (им управляет таймер, взводимый в обёртке "wrapPromiseTimeout" и передаваемый сюда в качестве сигнала) + if (options.signal && options.signal instanceof AbortSignal) { + signals.push(options.signal); + } + //Второй - на базе параметра "nTimeoutConn", получаемого из "Функции сервиса обмена", атрибут "Таймаут сетевого подключения" + signals.push(timeoutController.signal); + const combinedSignal = AbortSignal.any(signals); + //Взводим таймер для "второго" сигнала (по атрибуту "Таймаут сетевого подключения") + const timeoutId = + options.timeout > 0 + ? setTimeout( + () => timeoutController.abort(new Error(`Время ожидания выполнения запроса истекло после ${options.timeout} мс`)), + options.timeout + ) + : null; + //Выполняем запрос + try { + //Соберем параметры запроса в формате сервиса интеграции в формат, ожидаемый "fetch" + const fetchOptions = { + method: options.method, + headers: Object.fromEntries(headers), + body: requestBody, + signal: combinedSignal, + redirect: options.followRedirects ? "follow" : "manual" + }; + //Непосредственно запрос и чтение ответа + const response = await fetchImpl(url.toString(), fetchOptions); + //Читаем тело ответа + const responseBody = Buffer.from(await response.arrayBuffer()); + //Собираем ответ в формате, совместимом с сервисом интеграции + const result = { + statusCode: response.status, + statusMessage: response.statusText || "", + headers: Object.fromEntries(response.headers.entries()), + body: responseBody, + ok: response.ok, + url: response.url + }; + //Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней + if (options.throwOnErrorStatus && !result.ok) { + const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result); + const httpError = new Error(error.message); + httpError.response = result; + httpError.statusCode = result.statusCode; + throw httpError; + } + //Возвращаем ответ, предварительно обеспечив совместимомть со старыми библиотекаим "Request" + return processResponse(result, options); + } finally { + //Подчистка таймера - успели всё сделать до его истечения + if (timeoutId) clearTimeout(timeoutId); + } + } catch (e) { + //Преобразуем ошибки для совместимости с request-promise + if (e.name === "AbortError" || e.message.includes("время ожидания")) { + const timeoutError = new Error(e.message); + timeoutError.code = "ETIMEDOUT"; + timeoutError.error = { code: "ETIMEDOUT" }; + throw timeoutError; + } + //Если ошибка уже имеет response (из httpRequestNative или fetch), пробрасываем как есть + if (e.response) { + throw e; + } + //Полученную от нативных методов (httpRequestNative) "HttpError" преобразуем в обычный объект ошибки с дополнительными полями для совместимости + if (e instanceof HttpError) { + const httpError = new Error(e.message); + httpError.response = e.response; + httpError.statusCode = e.response.statusCode; + throw httpError; + } + //Для других ошибок добавляем поле error для совместимости + if (e.code) { + e.error = { code: e.code }; + } + //Наконец-то её можно отдать в вызывающее окружение + throw e; + } +}; + +//----------------- +// Интерфейс модуля +//----------------- + exports.HttpError = HttpError; +exports.httpRequest = httpRequest;