@andrey4emk/npm-app-back-b24 3.8.2 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -10
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +358 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +78 -0
- package/bitrix24/b24/retry.ts +478 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +278 -0
- package/bitrix24/b24/types.ts +66 -0
- package/bitrix24/b24.ts +60 -949
- package/bitrix24/eventB24.ts +233 -31
- package/package.json +10 -2
- package/sendMessage/email.ts +233 -32
- package/utils/fetchRetry.ts +70 -9
package/sendMessage/email.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import nodemailer from "nodemailer";
|
|
1
|
+
import nodemailer, { type SendMailOptions } from "nodemailer";
|
|
2
|
+
import { logs } from "../logs/logs.ts";
|
|
2
3
|
import { fetchWithTimeout, FETCH_TIMEOUTS } from "../utils/fetchRetry.ts";
|
|
3
4
|
|
|
4
5
|
// ==================== Типы ====================
|
|
@@ -9,6 +10,12 @@ interface EmailAuthParam {
|
|
|
9
10
|
pass: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/** Вложение письма */
|
|
14
|
+
interface EmailAttachment {
|
|
15
|
+
filename: string;
|
|
16
|
+
content: Buffer;
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
/** Данные для отправки письма */
|
|
13
20
|
interface EmailData {
|
|
14
21
|
to: string;
|
|
@@ -17,7 +24,7 @@ interface EmailData {
|
|
|
17
24
|
html?: string;
|
|
18
25
|
fileUrl?: string;
|
|
19
26
|
fileName?: string;
|
|
20
|
-
attachments?:
|
|
27
|
+
attachments?: EmailAttachment[] | null;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/** Результат отправки письма */
|
|
@@ -27,66 +34,260 @@ interface EmailResult {
|
|
|
27
34
|
info?: unknown;
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Ответ транспорта в той части, которую разбирает класс.
|
|
39
|
+
*
|
|
40
|
+
* Собственный узкий тип, а не `SentMessageInfo` из `@types/nodemailer`: там он
|
|
41
|
+
* объявлен как `any`, и в сигнатуру пакета это тянуть нельзя.
|
|
42
|
+
*/
|
|
43
|
+
export interface SendMailInfo {
|
|
44
|
+
accepted?: unknown;
|
|
45
|
+
rejected?: unknown;
|
|
46
|
+
response?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Минимальный контракт транспорта — ровно то, что использует класс.
|
|
51
|
+
*
|
|
52
|
+
* Второй аргумент конструктора существует ради тестов и подмены транспорта
|
|
53
|
+
* у потребителя; по умолчанию класс поднимает SMTP Яндекса сам.
|
|
54
|
+
*/
|
|
55
|
+
export interface MailTransport {
|
|
56
|
+
sendMail(mail: SendMailOptions): Promise<SendMailInfo>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ==================== Утилиты ====================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Грубая проверка адреса: непустая строка без пробелов, одна собака,
|
|
63
|
+
* непустые части слева и справа.
|
|
64
|
+
*
|
|
65
|
+
* Полная валидация адресов регуляркой — известная ловушка, и она здесь не нужна:
|
|
66
|
+
* цель — отсечь пустую строку и явный мусор, отказ сервера всё равно проверяется
|
|
67
|
+
* по ответу. Точку в домене не требуем: внутрикорпоративные адреса без точки существуют.
|
|
68
|
+
*/
|
|
69
|
+
function isLikelyEmail(value: unknown): value is string {
|
|
70
|
+
if (typeof value !== "string") return false;
|
|
71
|
+
if (/\s/.test(value)) return false;
|
|
72
|
+
|
|
73
|
+
const parts = value.split("@");
|
|
74
|
+
if (parts.length !== 2) return false;
|
|
75
|
+
|
|
76
|
+
const [local, domain] = parts;
|
|
77
|
+
return Boolean(local) && Boolean(domain);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Приводит адрес к той форме, в которой его возвращает почтовый сервер.
|
|
82
|
+
*
|
|
83
|
+
* Сравнивать «как передали» нельзя: конверт письма строит nodemailer, и в `accepted`
|
|
84
|
+
* приходит адрес из конверта — домен в punycode, угловые скобки сняты. Живой адрес
|
|
85
|
+
* `ivan@пример.рф` возвращался как `ivan@xn--e1afmkfd.xn--p1ai`, с исходным не совпадал,
|
|
86
|
+
* и доставленное письмо объявлялось недоставленным.
|
|
87
|
+
*
|
|
88
|
+
* Применяется к обеим сторонам сравнения. Локальная часть приводится к нижнему регистру
|
|
89
|
+
* намеренно: формально она регистрозависима, но здесь адрес сверяется сам с собой,
|
|
90
|
+
* а не с чужим.
|
|
91
|
+
*/
|
|
92
|
+
function normalizeAddress(value: string): string {
|
|
93
|
+
let address = value.trim();
|
|
94
|
+
|
|
95
|
+
if (address.startsWith("<") && address.endsWith(">")) {
|
|
96
|
+
address = address.slice(1, -1).trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Разделяем по последней собаке: в локальной части она допустима в кавычках
|
|
100
|
+
const separator = address.lastIndexOf("@");
|
|
101
|
+
if (separator <= 0) return address.toLowerCase();
|
|
102
|
+
|
|
103
|
+
const local = address.slice(0, separator);
|
|
104
|
+
const domain = address.slice(separator + 1);
|
|
105
|
+
|
|
106
|
+
let host: string;
|
|
107
|
+
try {
|
|
108
|
+
// Доступный в Node способ получить punycode-форму домена: разбор хоста в URL
|
|
109
|
+
// делает то же преобразование, что и nodemailer при построении конверта
|
|
110
|
+
host = new URL(`http://${domain}`).hostname;
|
|
111
|
+
} catch {
|
|
112
|
+
// Домен как хост не разбирается (пустой, с недопустимым символом) — сверяем как есть
|
|
113
|
+
host = domain.toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return `${local.toLowerCase()}@${host}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Приводит `accepted`/`rejected` к списку нормализованных адресов.
|
|
121
|
+
*
|
|
122
|
+
* Элементы приходят строкой либо объектом `{ name, address }` — форма зависит
|
|
123
|
+
* от того, как адрес был передан в `sendMail`.
|
|
124
|
+
*/
|
|
125
|
+
function toAddressList(value: unknown): string[] {
|
|
126
|
+
if (!Array.isArray(value)) return [];
|
|
127
|
+
|
|
128
|
+
const addresses: string[] = [];
|
|
129
|
+
|
|
130
|
+
for (const item of value as unknown[]) {
|
|
131
|
+
if (typeof item === "string") {
|
|
132
|
+
addresses.push(item);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (item && typeof item === "object") {
|
|
137
|
+
const address = (item as { address?: unknown }).address;
|
|
138
|
+
if (typeof address === "string") addresses.push(address);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return addresses.map((address) => normalizeAddress(address)).filter((address) => address !== "");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Имя для скачанного вложения: явно переданное `fileName`, иначе последний
|
|
147
|
+
* сегмент пути из URL (без query), иначе запасное `attachment`.
|
|
148
|
+
*
|
|
149
|
+
* Прежде здесь стояло `dataMail.fileName!`, и письмо с `fileUrl` без имени
|
|
150
|
+
* уезжало с вложением без имени файла.
|
|
151
|
+
*/
|
|
152
|
+
function resolveAttachmentName(fileUrl: string, fileName?: string): string {
|
|
153
|
+
const explicit = fileName?.trim();
|
|
154
|
+
if (explicit) return explicit;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const segment = new URL(fileUrl).pathname.split("/").pop();
|
|
158
|
+
const decoded = segment ? decodeURIComponent(segment) : "";
|
|
159
|
+
if (decoded) return decoded;
|
|
160
|
+
} catch {
|
|
161
|
+
// Адрес может быть относительным или битым — тогда берём запасное имя
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return "attachment";
|
|
165
|
+
}
|
|
166
|
+
|
|
30
167
|
// ==================== Класс ====================
|
|
31
168
|
|
|
32
169
|
export class Email {
|
|
33
170
|
private auth: EmailAuthParam;
|
|
34
|
-
private transporter:
|
|
171
|
+
private transporter: MailTransport;
|
|
35
172
|
|
|
36
|
-
constructor(auth: EmailAuthParam) {
|
|
173
|
+
constructor(auth: EmailAuthParam, transporter?: MailTransport) {
|
|
37
174
|
this.auth = auth;
|
|
38
|
-
this.transporter =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
175
|
+
this.transporter =
|
|
176
|
+
transporter ??
|
|
177
|
+
nodemailer.createTransport({
|
|
178
|
+
auth: {
|
|
179
|
+
user: this.auth.user,
|
|
180
|
+
pass: this.auth.pass,
|
|
181
|
+
},
|
|
182
|
+
host: "smtp.yandex.ru",
|
|
183
|
+
port: 465, // 465 = SMTPS, 587 = STARTTLS
|
|
184
|
+
secure: true, // true для 465
|
|
185
|
+
// Дефолты nodemailer (соединение 2 мин, приветствие 30с, сокет 10 мин) слишком щедры:
|
|
186
|
+
// письмо с вложением уходит на Яндекс за секунды, а 10 минут молчания сокета
|
|
187
|
+
// блокируют вызывающую очередь
|
|
188
|
+
connectionTimeout: 15_000,
|
|
189
|
+
greetingTimeout: 10_000,
|
|
190
|
+
socketTimeout: 120_000,
|
|
191
|
+
});
|
|
53
192
|
}
|
|
54
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Отправляет письмо получателю, копию — на адрес отправителя.
|
|
196
|
+
*
|
|
197
|
+
* Адрес получателя проверяется до вызова транспорта: пустой или мусорный `to`
|
|
198
|
+
* раньше давал «успешную» отправку копии себе и ни одной жалобы в логе.
|
|
199
|
+
*
|
|
200
|
+
* Вложения вызывающего (`attachments`) сохраняются и дополняются файлом,
|
|
201
|
+
* скачанным по `fileUrl`. Входной объект не мутируется.
|
|
202
|
+
*
|
|
203
|
+
* @param dataMail — адресат, тема, тело письма, вложения
|
|
204
|
+
* @returns `{ error, message?, info? }`; `error: true` приходит и тогда, когда
|
|
205
|
+
* сервер принял копию отправителю, но отверг получателя
|
|
206
|
+
*/
|
|
55
207
|
async send(dataMail: EmailData): Promise<EmailResult> {
|
|
56
208
|
try {
|
|
57
|
-
|
|
209
|
+
const to = typeof dataMail.to === "string" ? dataMail.to.trim() : "";
|
|
210
|
+
if (!isLikelyEmail(to)) {
|
|
211
|
+
return { error: true, message: `Email: адрес получателя некорректен ('${String(dataMail.to)}') — письмо не отправлено` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Вложения вызывающего не затираем и входной объект не мутируем:
|
|
215
|
+
// `attachments` и пара `fileUrl`/`fileName` объявлены независимыми полями,
|
|
216
|
+
// ни одно не документировано как «вместо». Тот, кто заполнил оба, хочет оба;
|
|
217
|
+
// замена молча теряла данные. Порядок: сначала переданные, затем скачанное
|
|
218
|
+
const attachments: EmailAttachment[] = [...(dataMail.attachments ?? [])];
|
|
219
|
+
|
|
58
220
|
if (dataMail.fileUrl) {
|
|
59
221
|
const resDownloadFile = await this.downloadFileToUrl(dataMail.fileUrl);
|
|
60
|
-
if (resDownloadFile.error) {
|
|
222
|
+
if (resDownloadFile.error || !resDownloadFile.buffer) {
|
|
61
223
|
// Подстановка вместо возможного undefined: при exactOptionalPropertyTypes
|
|
62
224
|
// у потребителя явный undefined не подходит полю message?: string
|
|
63
225
|
return { error: true, message: resDownloadFile.message ?? "Не удалось скачать файл по fileUrl" };
|
|
64
226
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
];
|
|
71
|
-
} else {
|
|
72
|
-
dataMail.attachments = null;
|
|
227
|
+
|
|
228
|
+
attachments.push({
|
|
229
|
+
filename: resolveAttachmentName(dataMail.fileUrl, dataMail.fileName),
|
|
230
|
+
content: resDownloadFile.buffer,
|
|
231
|
+
});
|
|
73
232
|
}
|
|
74
233
|
|
|
75
234
|
const info = await this.transporter.sendMail({
|
|
76
235
|
from: `Натяжные потолки Репа. <${this.auth.user}>`,
|
|
77
|
-
to: [
|
|
236
|
+
to: [to, this.auth.user],
|
|
78
237
|
subject: dataMail.subject,
|
|
79
238
|
text: dataMail.text,
|
|
80
239
|
html: dataMail.html,
|
|
81
|
-
attachments:
|
|
240
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
82
241
|
});
|
|
83
|
-
|
|
242
|
+
|
|
243
|
+
return this.checkDelivery(to, info);
|
|
84
244
|
} catch (error: unknown) {
|
|
85
245
|
const message = error instanceof Error ? error.message : String(error);
|
|
86
246
|
return { error: true, message };
|
|
87
247
|
}
|
|
88
248
|
}
|
|
89
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Проверяет по ответу транспорта, принял ли сервер основного адресата.
|
|
252
|
+
*
|
|
253
|
+
* В `to` всегда уходит пара «получатель + отправитель», а `sendMail` резолвится,
|
|
254
|
+
* если принят хотя бы один адрес. Без этой проверки отказ по получателю
|
|
255
|
+
* возвращался вызывающему как `error: false`.
|
|
256
|
+
*
|
|
257
|
+
* Отказ логируется не здесь: результат уходит вызывающему, а уровень `error`
|
|
258
|
+
* дублировался бы в чат B24. От таймаутов отправки в `Smsgold`/`Wappi` случай
|
|
259
|
+
* отличается тем, что статус известен — сервер прямо сказал «не принял».
|
|
260
|
+
*/
|
|
261
|
+
private checkDelivery(to: string, info: SendMailInfo): EmailResult {
|
|
262
|
+
const hasReport = Array.isArray(info?.accepted) || Array.isArray(info?.rejected);
|
|
263
|
+
|
|
264
|
+
// Транспорт вообще не сообщил, какие адреса приняты. Отказ здесь не выдумываем:
|
|
265
|
+
// у SMTP-транспорта эти поля есть всегда, а объявить успешно доставленное письмо
|
|
266
|
+
// ошибкой — регресс хуже исходного дефекта. Успех плюс строка в лог
|
|
267
|
+
if (!hasReport) {
|
|
268
|
+
logs.add("Email: транспорт не сообщил, какие адреса приняты — проверить доставку нечем", "warn");
|
|
269
|
+
return { error: false, info };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Обе стороны сравнения нормализуем одинаково: сервер отдаёт адреса из конверта,
|
|
273
|
+
// а не в том виде, в каком их передали
|
|
274
|
+
const target = normalizeAddress(to);
|
|
275
|
+
const accepted = toAddressList(info.accepted);
|
|
276
|
+
const rejected = toAddressList(info.rejected);
|
|
277
|
+
|
|
278
|
+
if (rejected.includes(target) || !accepted.includes(target)) {
|
|
279
|
+
const response = typeof info.response === "string" && info.response.trim() !== "" ? ` Ответ сервера: ${info.response}` : "";
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
error: true,
|
|
283
|
+
message: `Email: почтовый сервер не принял адрес получателя ${to}, письмо ему не ушло (копия отправителю могла уйти).${response}`,
|
|
284
|
+
info,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { error: false, info };
|
|
289
|
+
}
|
|
290
|
+
|
|
90
291
|
private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
|
|
91
292
|
try {
|
|
92
293
|
const response = await fetchWithTimeout(fileUrl, undefined, FETCH_TIMEOUTS.transfer);
|
package/utils/fetchRetry.ts
CHANGED
|
@@ -31,7 +31,15 @@ function isValidTimeout(value: number): boolean {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* Верхняя граница подозрительного бюджета: значение от 1 до 999 мс почти наверняка
|
|
35
|
+
* означает, что человек написал секунды вместо миллисекунд.
|
|
36
|
+
*
|
|
37
|
+
* Ноль сюда не входит — это задокументированное выключение таймаута.
|
|
38
|
+
*/
|
|
39
|
+
const SUSPICIOUS_TIMEOUT_MAX_MS = 999;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Читает бюджет запроса из переменной окружения `name`.
|
|
35
43
|
*
|
|
36
44
|
* `dotenv.config()` вызывается в `logs/logs.ts`, а этот модуль импортирует `logs`
|
|
37
45
|
* первой строкой — значит к моменту чтения `process.env` файл `.env` уже загружен.
|
|
@@ -39,15 +47,38 @@ function isValidTimeout(value: number): boolean {
|
|
|
39
47
|
* Значение принимается, только если это целое число >= 0 (0 означает «без таймаута»).
|
|
40
48
|
* Любой мусор в переменной не должен молча превращаться в `NaN` или в обрезанное
|
|
41
49
|
* `parseInt`-число, поэтому проверяем фактом и падаем на дефолт с записью в лог.
|
|
50
|
+
* Пустая строка и строка из пробелов считаются «переменная не задана» — молча дефолт.
|
|
51
|
+
*
|
|
52
|
+
* Значение от 1 до 999 применяется как есть, но с записью `warn`: столько миллисекунд
|
|
53
|
+
* не хватит ни одному живому запросу, и это типовая путаница секунд с миллисекундами.
|
|
54
|
+
* Молча такое значение пропускать нельзя — на бюджетах отправки оно превращается
|
|
55
|
+
* в массовую недоставку с «неизвестным статусом» и потоком `error` в чат портала.
|
|
56
|
+
*
|
|
57
|
+
* Экспортируется по двум причинам: одинаковая семантика для всех бюджетов пакета
|
|
58
|
+
* (дефолт `fetchRetry` и каждый из `FETCH_TIMEOUTS` читаются одной и той же функцией)
|
|
59
|
+
* и проверяемость тестом без запуска дочернего процесса.
|
|
60
|
+
*
|
|
61
|
+
* Важно: все чтения происходят **один раз при загрузке модуля**. Правка `process.env`
|
|
62
|
+
* в рантайме на уже вычисленные константы не влияет.
|
|
63
|
+
*
|
|
64
|
+
* @param name — имя переменной окружения
|
|
65
|
+
* @param fallback — значение, если переменная не задана или непригодна
|
|
66
|
+
* @returns бюджет в мс
|
|
42
67
|
*/
|
|
43
|
-
function readTimeoutFromEnv(): number {
|
|
44
|
-
const raw = process.env
|
|
45
|
-
if (raw === undefined || raw.trim() === "") return
|
|
68
|
+
export function readTimeoutFromEnv(name: string, fallback: number): number {
|
|
69
|
+
const raw = process.env[name];
|
|
70
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
46
71
|
|
|
47
72
|
const parsed = Number(raw);
|
|
48
73
|
if (!isValidTimeout(parsed)) {
|
|
49
|
-
logs.add(
|
|
50
|
-
return
|
|
74
|
+
logs.add(`${name}: значение '${raw}' непригодно (нужно целое от 0 до ${MAX_TIMEOUT_MS}) — берём ${fallback}мс`, "warn");
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Значение применяем как есть: аварийный рычаг остаётся в руках у человека,
|
|
79
|
+
// мы только делаем подозрительный ввод видимым
|
|
80
|
+
if (parsed >= 1 && parsed <= SUSPICIOUS_TIMEOUT_MAX_MS) {
|
|
81
|
+
logs.add(`${name}: значение '${raw}' похоже на секунды, а нужны миллисекунды — бюджет ${parsed}мс применён как есть`, "warn");
|
|
51
82
|
}
|
|
52
83
|
|
|
53
84
|
return parsed;
|
|
@@ -58,7 +89,13 @@ function readTimeoutFromEnv(): number {
|
|
|
58
89
|
* из `FETCH_TIMEOUT_MS`, иначе 60 000. Значение 0 полностью выключает таймаут —
|
|
59
90
|
* это аварийный рычаг для случая, когда дефолт обрывает живой долгий запрос.
|
|
60
91
|
*/
|
|
61
|
-
export const DEFAULT_FETCH_TIMEOUT_MS: number = readTimeoutFromEnv();
|
|
92
|
+
export const DEFAULT_FETCH_TIMEOUT_MS: number = readTimeoutFromEnv("FETCH_TIMEOUT_MS", FALLBACK_TIMEOUT_MS);
|
|
93
|
+
|
|
94
|
+
/** Дефолты именованных бюджетов до чтения окружения, мс */
|
|
95
|
+
const FALLBACK_TIMEOUTS = { quick: 10_000, api: 20_000, send: 30_000, transfer: 60_000 } as const;
|
|
96
|
+
|
|
97
|
+
/** Имя именованного бюджета пакета */
|
|
98
|
+
export type FetchTimeoutName = keyof typeof FALLBACK_TIMEOUTS;
|
|
62
99
|
|
|
63
100
|
/**
|
|
64
101
|
* Именованные бюджеты для вызовов внутри пакета: смысл каждого запроса тут известен,
|
|
@@ -68,8 +105,32 @@ export const DEFAULT_FETCH_TIMEOUT_MS: number = readTimeoutFromEnv();
|
|
|
68
105
|
* - `api` — короткое чтение (проверка номера, список лицензий, получение контакта);
|
|
69
106
|
* - `send` — отправка текста, создание контакта, отправка SMS;
|
|
70
107
|
* - `transfer` — скачивание файла и отправка файла/вложения.
|
|
108
|
+
*
|
|
109
|
+
* Каждый бюджет переопределяется своей переменной окружения:
|
|
110
|
+
*
|
|
111
|
+
* | Ключ | Переменная | Дефолт, мс |
|
|
112
|
+
* |---|---|---|
|
|
113
|
+
* | `quick` | `FETCH_TIMEOUT_QUICK_MS` | 10 000 |
|
|
114
|
+
* | `api` | `FETCH_TIMEOUT_API_MS` | 20 000 |
|
|
115
|
+
* | `send` | `FETCH_TIMEOUT_SEND_MS` | 30 000 |
|
|
116
|
+
* | `transfer` | `FETCH_TIMEOUT_TRANSFER_MS` | 60 000 |
|
|
117
|
+
*
|
|
118
|
+
* Значения читаются один раз при загрузке модуля: правка `process.env` в рантайме
|
|
119
|
+
* их не двигает, менять бюджет нужно до старта процесса.
|
|
120
|
+
*
|
|
121
|
+
* Значение 0 выключает таймаут для этого бюджета целиком. Это аварийный рычаг,
|
|
122
|
+
* и у отправок (`send`, `transfer`) он снимает заодно защиту «неизвестный статус
|
|
123
|
+
* доставки»: при нуле зависшая отправка снова держится до таймаутов undici,
|
|
124
|
+
* а `unknownDelivery` в `Smsgold` не выставится никогда.
|
|
125
|
+
*
|
|
126
|
+
* Непригодное значение защиту молча не выключает: одна строка `warn` и дефолт.
|
|
71
127
|
*/
|
|
72
|
-
export const FETCH_TIMEOUTS
|
|
128
|
+
export const FETCH_TIMEOUTS: Readonly<Record<FetchTimeoutName, number>> = {
|
|
129
|
+
quick: readTimeoutFromEnv("FETCH_TIMEOUT_QUICK_MS", FALLBACK_TIMEOUTS.quick),
|
|
130
|
+
api: readTimeoutFromEnv("FETCH_TIMEOUT_API_MS", FALLBACK_TIMEOUTS.api),
|
|
131
|
+
send: readTimeoutFromEnv("FETCH_TIMEOUT_SEND_MS", FALLBACK_TIMEOUTS.send),
|
|
132
|
+
transfer: readTimeoutFromEnv("FETCH_TIMEOUT_TRANSFER_MS", FALLBACK_TIMEOUTS.transfer),
|
|
133
|
+
};
|
|
73
134
|
|
|
74
135
|
/** Читает `name` с произвольного значения ошибки, не полагаясь на её тип */
|
|
75
136
|
function getErrorName(error: unknown): string | null {
|
|
@@ -135,7 +196,7 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
135
196
|
|
|
136
197
|
/**
|
|
137
198
|
* Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
|
|
138
|
-
* хост отверг подключение.
|
|
199
|
+
* хост отверг подключение. Соединение с целевым хостом не состоялось.
|
|
139
200
|
*/
|
|
140
201
|
const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "EAI_NODATA", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN"];
|
|
141
202
|
|