/* Сервис интеграции ПП Парус 8 с WEB API Модуль ядра: работа с HTTP/HTTPS запросами */ //------------------------------ // Подключение внешних библиотек //------------------------------ const { URL } = require("url"); //-------------------------- // Локальные идентификаторы //-------------------------- const DEFAULT_TIMEOUT = 30000; //Таймаут по умолчанию //Выполнение 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, json: options.json, headers }); //Если не указан размер тела if (contentLength !== undefined && !headers.has("content-length")) { //Установим размер тела в заголовок headers.set("content-length", String(contentLength)); } //Выполним запрос с использованием fetch return httpRequestFetch({ options, url, headers, body, isStream }); } catch (e) { throw e; } }; //Ошибка HTTP-запроса class HttpError extends Error { constructor(message, response) { super(message); this.name = "HttpError"; this.response = response; } } //Выполнение запроса с использованием fetch const httpRequestFetch = async ({ options, url, headers, body, isStream }) => { try { const fetchImpl = globalThis.fetch; if (typeof fetchImpl !== "function") { throw new Error("globalThis.fetch недоступен. Используйте Node.js >= 18."); } 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}`); } } 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" }; if (options.proxy || options.ca || options.cert || options.key || options.passphrase || options.rejectUnauthorized === false) { const { Agent, ProxyAgent } = require("undici"); const connectOptions = { rejectUnauthorized: options.rejectUnauthorized !== undefined ? options.rejectUnauthorized : true }; if (options.ca) { connectOptions.ca = Array.isArray(options.ca) ? options.ca : [options.ca]; } if (options.cert) { connectOptions.cert = options.cert; } if (options.key) { connectOptions.key = options.key; } if (options.passphrase) { connectOptions.passphrase = options.passphrase; } if (options.proxy) { const proxyUrl = new URL(options.proxy); if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") { throw new Error("Поддерживаются только HTTP/HTTPS-прокси (protocol=http|https)."); } const proxyAuth = proxyUrl.username || proxyUrl.password ? `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString( "base64" )}` : null; fetchOptions.dispatcher = new ProxyAgent({ uri: options.proxy, token: proxyAuth, connect: connectOptions }); } else { fetchOptions.dispatcher = new Agent({ connect: connectOptions }); } } const response = await fetchImpl(url.toString(), fetchOptions); const responseBody = Buffer.from(await response.arrayBuffer()); const result = { statusCode: response.status, headers: Object.fromEntries(response.headers.entries()), body: responseBody, ok: response.ok, url: response.url }; if (options.throwOnErrorStatus && !result.ok) { throw new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result); } return result; } finally { if (timeoutId) clearTimeout(timeoutId); } } catch (e) { throw e; } }; //Нормализация параметров запроса const normalizeOptions = options => { if (!options || typeof options !== "object") { throw new TypeError("options должен быть объектом"); } if (!options.url) throw new Error("options.url обязателен"); return { method: (options.method || "GET").toUpperCase(), url: options.url, headers: options.headers || {}, query: options.query || options.qs || {}, body: options.body, json: options.json, timeout: options.timeout ?? DEFAULT_TIMEOUT, followRedirects: options.followRedirects ?? false, 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 ?? false, signal: options.signal || 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, json, headers }) => { if (json !== undefined) { const payload = Buffer.from(JSON.stringify(json)); if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8"); return { body: payload, contentLength: payload.length, isStream: false }; } if (body === undefined || body === null) return { body: undefined, contentLength: undefined, 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 }; } 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 }; }; exports.httpRequest = httpRequest; exports.HttpError = HttpError;