/* Сервис интеграции ПП Парус 8 с WEB API Модуль ядра: работа с HTTP/HTTPS запросами */ //------------------------------ // Подключение внешних библиотек //------------------------------ const http = require("http"); const https = require("https"); const { URL } = require("url"); const { Socket } = require("net"); //-------------------------- // Локальные идентификаторы //-------------------------- const DEFAULT_TIMEOUT = 30000; //Таймаут по умолчанию //Ошибка HTTP-запроса class HttpError extends Error { constructor(message, response) { super(message); this.name = "HttpError"; this.response = response; } } //Обработка ответа (парсинг 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; } }; //Нормализация параметров запроса const normalizeOptions = options => { if (!options || typeof options !== "object") { throw new TypeError("options должен быть объектом"); } //Поддержка параметра uri (как в request-promise) const url = options.uri || options.url; if (!url) throw new Error("options.url или options.uri обязателен"); //Обработаем 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; } else if (jsonOption && typeof jsonOption === "object") { if (body === undefined) { body = jsonOption; jsonRequest = true; } json = true; } } //Определяем HTTP-метод let method = options.method; if (!method) { const hasRequestBody = body != null; method = hasRequestBody ? "POST" : "GET"; } return { method: String(method).toUpperCase(), url, headers: options.headers || {}, query: options.query || options.qs || {}, body, json, jsonRequest, timeout: options.timeout ?? DEFAULT_TIMEOUT, followRedirects: options.followRedirects ?? true, proxy: options.proxy || null, ca: options.ca, cert: options.cert, key: options.key, passphrase: options.passphrase, rejectUnauthorized: options.rejectUnauthorized !== undefined ? options.rejectUnauthorized : true, throwOnErrorStatus: options.throwOnErrorStatus ?? options.simple !== false, signal: options.signal || null, resolveWithFullResponse: options.resolveWithFullResponse || false, encoding: options.encoding || null }; }; //Формирование ссылки const buildURL = (base, query = {}) => { const url = new URL(base); Object.entries(query).forEach(([key, value]) => { if (value === undefined || value === null) return; url.searchParams.append(key, String(value)); }); return url; }; //Подготовка заголовков запроса const prepareHeaders = (inputHeaders = {}) => { const headers = new Map(); Object.entries(inputHeaders).forEach(([key, value]) => { if (value === undefined || value === null) return; headers.set(String(key).toLowerCase(), String(value)); }); return headers; }; //Подготовка тела запроса 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; exports.HttpError = HttpError;