/* Сервис интеграции ПП Парус 8 с WEB API Модуль ядра: работа с HTTP/HTTPS запросами */ //------------------------------ // Подключение внешних библиотек //------------------------------ const http = require("http"); //Встроенная поддержка HTTP const https = require("https"); //Встроенная поддержка HTTPS const { URL } = require("url"); //Обслуживание типовых 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; } } //------------------------ // Вспомогательные функции //------------------------ //Нормализация параметров запроса (для совместимости с ранее применяемыми библиотеками "Request" и "Request-Promice") const normalizeOptions = options => { //Проверим, что нам предлагают обработать объект if (!options || typeof options !== "object") { throw new TypeError("Параметры HTTP-запроса должны быть объектом"); } //Поддержка параметра 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 }; }; //Обработка ответа (парсинг 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;