P8-ExchangeService/core/http_client.js

615 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Сервис интеграции ПП Парус 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;