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