@andrey4emk/npm-app-back-b24 3.7.1 → 3.8.1
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 +128 -12
- package/bitrix24/b24.ts +103 -20
- package/bitrix24/eventB24.ts +54 -5
- package/logs/logs.ts +19 -1
- package/package.json +5 -2
- package/sendMessage/chatApp.ts +192 -95
- package/sendMessage/email.ts +11 -2
- package/sendMessage/smsgold.ts +57 -12
- package/sendMessage/wappi.ts +144 -53
- package/utils/fetchRetry.ts +251 -8
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
- [Email](#email)
|
|
16
16
|
- [Wappi](#wappi)
|
|
17
17
|
- [Логирование](#логирование)
|
|
18
|
-
- [fetchRetry](#fetchretry)
|
|
18
|
+
- [fetchRetry и таймауты](#fetchretry-и-таймауты)
|
|
19
19
|
- [Переменные окружения](#переменные-окружения)
|
|
20
20
|
- [Скрипты](#скрипты)
|
|
21
21
|
- [Лицензия](#лицензия)
|
|
@@ -32,7 +32,7 @@ npm install @andrey4emk/npm-app-back-b24
|
|
|
32
32
|
| --------------------------------------------- | ----------------------------- | ---------------------- |
|
|
33
33
|
| `@andrey4emk/npm-app-back-b24` | всё | да |
|
|
34
34
|
| `@andrey4emk/npm-app-back-b24/logs` | `logs` | нет |
|
|
35
|
-
| `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `isPreConnectionError`, `maskUrl` | нет |
|
|
35
|
+
| `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `fetchWithTimeout`, `isNetworkError`, `isPreConnectionError`, `isTimeoutError`, `isAbortError`, `maskUrl`, `FETCH_TIMEOUTS`, `DEFAULT_FETCH_TIMEOUT_MS` | нет |
|
|
36
36
|
|
|
37
37
|
Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
|
|
38
38
|
|
|
@@ -50,6 +50,41 @@ import { fetchRetry, maskUrl } from "@andrey4emk/npm-app-back-b24/fetchRetry";
|
|
|
50
50
|
|
|
51
51
|
Практическое следствие: индексация массива внутри пакета всегда сопровождается проверкой на `undefined`. Если в своём проекте эти флаги включены — ошибок из `node_modules/@andrey4emk/npm-app-back-b24` быть не должно.
|
|
52
52
|
|
|
53
|
+
### Nuxt: сборка в Nitro и импорт `$b24`
|
|
54
|
+
|
|
55
|
+
Пакет публикуется исходниками на TypeScript, а Nitro по умолчанию не транспилирует ничего из `node_modules`. Двух ловушек здесь достаточно, чтобы потерять полдня, — обе проверены на Nuxt 4.5.
|
|
56
|
+
|
|
57
|
+
**Сборка.** Одного `nitro.externals.inline` мало: Nuxt жёстко ставит `esbuild.options.exclude = [/node_modules/]`, а `defu` при слиянии конфигов массивы **склеивает**, а не заменяет — переопределить это из `nuxt.config` нельзя. Rollup получает сырой TypeScript и падает на `Expected '{', got 'interface'`. Рабочая комбинация — inline плюс хук `nitro:config`, который присваивает `exclude` заново:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// nuxt.config.ts
|
|
61
|
+
export default defineNuxtConfig({
|
|
62
|
+
nitro: { externals: { inline: ["@andrey4emk/npm-app-back-b24"] } },
|
|
63
|
+
hooks: {
|
|
64
|
+
"nitro:config"(nitroConfig) {
|
|
65
|
+
// ЗАМЕНЯЕТ exclude, а не дополняет — иначе /node_modules/ из дефолта победит
|
|
66
|
+
nitroConfig.esbuild.options.exclude = [/node_modules\/(?!@andrey4emk\/)/];
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Импорт `$b24`.** Это `export let`, а не `const`: `reinitializeB24()` переприсваивает биндинг. Захват значения в момент импорта (`const b24 = $b24` на уровне модуля) навсегда оставит то, что было при загрузке, — как правило `null`, потому что токены к этому моменту ещё не прочитаны. Читать свойство нужно в момент вызова:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// server/utils/b24.ts
|
|
76
|
+
import { $b24 } from "@andrey4emk/npm-app-back-b24";
|
|
77
|
+
|
|
78
|
+
// Геттер, а не const: биндинг $b24 меняется после reinitializeB24()
|
|
79
|
+
export const getB24 = () => $b24;
|
|
80
|
+
|
|
81
|
+
// Использование
|
|
82
|
+
const b24 = getB24();
|
|
83
|
+
if (b24) {
|
|
84
|
+
const res = await b24.callMethod("crm.deal.get", { id: 123 });
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
53
88
|
## Использование
|
|
54
89
|
|
|
55
90
|
```js
|
|
@@ -70,8 +105,11 @@ import {
|
|
|
70
105
|
Wappi,
|
|
71
106
|
logs,
|
|
72
107
|
fetchRetry,
|
|
108
|
+
fetchWithTimeout,
|
|
73
109
|
isNetworkError,
|
|
110
|
+
isTimeoutError,
|
|
74
111
|
maskUrl,
|
|
112
|
+
FETCH_TIMEOUTS,
|
|
75
113
|
} from "@andrey4emk/npm-app-back-b24";
|
|
76
114
|
|
|
77
115
|
// $b24 — готовый экземпляр B24OAuth (или null, если токены не настроены)
|
|
@@ -119,8 +157,11 @@ await wappi.sendMessageWappi("whatsApp", { phone: "+1234567890", message: "Пр
|
|
|
119
157
|
// Логирование
|
|
120
158
|
logs.add("Сообщение", "info");
|
|
121
159
|
|
|
122
|
-
// fetch с повторными попытками при сетевых ошибках
|
|
160
|
+
// fetch с повторными попытками при сетевых ошибках (бюджет попытки — 60с)
|
|
123
161
|
const response = await fetchRetry("https://api.example.com/data", { method: "GET" });
|
|
162
|
+
|
|
163
|
+
// Один запрос без повторов — для неидемпотентных операций
|
|
164
|
+
const sent = await fetchWithTimeout("https://api.example.com/send", { method: "POST" }, FETCH_TIMEOUTS.send);
|
|
124
165
|
```
|
|
125
166
|
|
|
126
167
|
## API
|
|
@@ -167,15 +208,21 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
167
208
|
|
|
168
209
|
**Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
|
|
169
210
|
|
|
170
|
-
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина:
|
|
211
|
+
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. `NETWORK_ERROR` приходит и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
|
|
171
212
|
|
|
172
213
|
Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
|
|
173
214
|
|
|
174
|
-
Важно:
|
|
215
|
+
Важно: **транспортные сбои SDK не повторяет вообще** — пакет задаёт параметры ограничителя при создании клиента. Иначе гейт был бы дырявым: SDK не различает идемпотентные и создающие вызовы и повторил бы оборванный `crm.deal.add` до трёх раз внутри себя, куда наша защита не дотягивается. Измерено: без этих параметров обрыв соединения, 502 и 504 дают по три выполнения на портале, с ними — по одному.
|
|
216
|
+
|
|
217
|
+
Повторы SDK при 429 и 503 сохранены: портал отвечает отказом, не выполнив запрос.
|
|
218
|
+
|
|
219
|
+
Отсюда следствие для вашего кода: **не вызывайте `setRestrictionManagerParams()` и не поднимайте `initB24Helper()` / `B24HelperManager` на `$b24`** — они перезаписывают настройки ограничителя целиком, не сливая, и молча вернут повторы SDK вместе с риском дубликатов.
|
|
175
220
|
|
|
176
221
|
**Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
|
|
177
222
|
|
|
178
|
-
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но
|
|
223
|
+
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но на транспортных ошибках он выключен — там одна попытка, повторяет только Proxy. При 429, 503 и неизвестных 5xx SDK повторяет по-прежнему.
|
|
224
|
+
|
|
225
|
+
Отдельно про ответы шлюза перед порталом. **502** повторяет наш слой: шлюз не смог получить ответ от upstream, запрос почти наверняка не выполнялся. **504** не повторяет никто — это «портал взял запрос и считает прямо сейчас»; повтор создающего вызова дал бы дубликат, а читающего — ничего, кроме нагрузки на портал в худший для него момент.
|
|
179
226
|
|
|
180
227
|
**Диагностика SDK.** Начиная с 3.7.0 собственные сообщения SDK от уровня `WARNING` и выше попадают в лог пакета с префиксом `SDK B24:` вместе с контекстом (`requestId`, `method`, `status`, код ошибки) — например предупреждение `fetchList` про игнорируемый `order` или про остановку пагинации, когда `idKey` не совпал с полем ответа. Порог нужен: на уровне `info` SDK пишет две строки на каждый запрос. Ошибки SDK приходят как `warn`, а не `error` — это диагностика отдельной неуспешной попытки, а окончательный провал вызова один раз пишет retry-слой.
|
|
181
228
|
|
|
@@ -427,6 +474,8 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
427
474
|
processId: "12345",
|
|
428
475
|
message: [
|
|
429
476
|
{
|
|
477
|
+
messageId: "7cf5d29bee90cc37382a9fff07fc2a43", // MESSAGE_ID записи очереди — ключ для clear()
|
|
478
|
+
eventId: "4821", // ID записи очереди, информационное; в clear() не передавать
|
|
430
479
|
connectorId: "...",
|
|
431
480
|
lineId: "...",
|
|
432
481
|
chatId: "...",
|
|
@@ -454,6 +503,8 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
454
503
|
- Автоматически фильтрует и удаляет события от системного пользователя (`user_id: '138'`).
|
|
455
504
|
- Для коннекторных событий конвертирует файлы в формат с типами `image`, `video`, `document`.
|
|
456
505
|
- Если событий нет, возвращает `data` с пустыми массивами.
|
|
506
|
+
- **Гранулярность очистки — запись очереди, а не сообщение.** Одна запись коннектора (один `MESSAGE_ID`) может нести несколько сообщений, и они приходят отдельными элементами `message` с одинаковым `messageId`. Очистка по этому ключу удаляет запись целиком: если из двух сообщений одной записи ушло одно, `clear()` заберёт и неотправленное. Строить поэлементную очистку с точностью до сообщения на `messageId` нельзя. Для дедупликации отдельных сообщений он не годится по той же причине — у сообщений одной записи он общий, для этого есть `im.id`. Обратная сторона: запись, из которой не разобрано ни одного сообщения, в `data.message` не попадёт вовсе, и очистка по собранным `messageId` её не заберёт. Безопасный режим — `clear(processId)` целиком.
|
|
507
|
+
- Типы `ConnectorMessage`, `ConnectorEvents` и `EntityEvents` экспортируются из пакета.
|
|
457
508
|
|
|
458
509
|
```js
|
|
459
510
|
const { error, data } = await event.get("ONCRMDYNAMICITEMUPDATE_149");
|
|
@@ -467,7 +518,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
467
518
|
- **Параметры:**
|
|
468
519
|
|
|
469
520
|
- `processId` (string) — ID процесса (получен из `get()`).
|
|
470
|
-
- `messageId` (string|string[]) — опциональный
|
|
521
|
+
- `messageId` (string|string[]) — опциональный `MESSAGE_ID` или массив `MESSAGE_ID` записей очереди для очистки. Без него очищается весь пакет `processId`. Пустой массив означает «очищать нечего»: вызов до API не доходит и возвращает `error: false` (раньше уезжало `message_id: []`). «Мягкая» ошибка портала, например `ERROR_ENTITY_NOT_FOUND` на протухшем `processId`, возвращается как `error: true`.
|
|
471
522
|
|
|
472
523
|
- **Возвращает:** промис с объектом `{ error, message, data }`.
|
|
473
524
|
|
|
@@ -501,6 +552,8 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
501
552
|
|
|
502
553
|
Конструктор сразу разрешает тип мессенджера для `whatsApp` и `telegram` — берёт `messenger[0].type` и держит в полях, вместо того чтобы индексировать массив при каждой отправке. Если конфига нет или список `messenger` пуст, тип остаётся неразрешённым, и **методы этого мессенджера возвращают** `{ error: true, message: "ChatApp: не разрешён тип мессенджера whatsApp (нет конфига или пуст messenger)", data: null }` — вместо запроса на URL с `undefined` в пути. Конструктор при этом **не бросает**: экземпляр обычно создаётся на уровне модуля, и бросок уронил бы загрузку всего модуля отправки вместе с соседними каналами.
|
|
503
554
|
|
|
555
|
+
**Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в чат B24) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
|
|
556
|
+
|
|
504
557
|
Отказ локальный: пустой `whatsApp` не мешает `telegram`. Но конфиг всё равно нужен для **обоих** мессенджеров, даже если проект пользуется одним — иначе второй канал молча перестанет работать, и узнать об этом можно будет только по `res.error` в логе.
|
|
505
558
|
|
|
506
559
|
**Методы:**
|
|
@@ -564,6 +617,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
564
617
|
- `messengerType` (string) — `"whatsApp"` или `"telegram"`.
|
|
565
618
|
- `phone` (string) — номер телефона.
|
|
566
619
|
- **Возвращает:** промис с объектом `{ error, message, data }`, где `data.check` — результат проверки.
|
|
620
|
+
- **Особенности:** если API ответил `success: true`, но без объекта `data`, метод возвращает `{ error: true, message: "Ответ ChatApp без поля data ...", data }` с сырым ответом. Раньше в этом случае наружу летел `TypeError` — метод не обёрнут в `try/catch`. Подставлять `check: undefined` было бы хуже: вызывающий принял бы непонятный ответ за «телефон не найден».
|
|
567
621
|
|
|
568
622
|
### Smsgold
|
|
569
623
|
|
|
@@ -574,10 +628,13 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
574
628
|
|
|
575
629
|
const smsClient = new Smsgold(
|
|
576
630
|
{ user: "smsgold_login", pass: "smsgold_password" },
|
|
577
|
-
b24Instance // опционально — экземпляр B24OAuth для загрузки файлов на диск
|
|
631
|
+
b24Instance, // опционально — экземпляр B24OAuth для загрузки файлов на диск
|
|
632
|
+
123456 // опционально — ID папки на диске B24 для вложений, по умолчанию 2792881
|
|
578
633
|
);
|
|
579
634
|
```
|
|
580
635
|
|
|
636
|
+
Третий параметр — папка, куда кладётся файл из `fileUrl` перед отправкой SMS. Дефолт `2792881` — папка «contract_company» портала `repacheb.bitrix24.ru`; на другом портале такого ID нет, и его нужно передать своим. Параметр нужен только при отправке файлов.
|
|
637
|
+
|
|
581
638
|
**Методы:**
|
|
582
639
|
|
|
583
640
|
- **`sendSms(messageData)`** — отправить SMS.
|
|
@@ -588,7 +645,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
588
645
|
- `message` (string) — текст сообщения.
|
|
589
646
|
- `fileUrl` (string) — опционально, ссылка на файл (будет загружен на диск Bitrix24 и публичная ссылка добавлена в SMS).
|
|
590
647
|
- `fileName` (string) — опционально, имя файла.
|
|
591
|
-
- **Возвращает:** промис с объектом `{ error, message, result }`.
|
|
648
|
+
- **Возвращает:** промис с объектом `{ error, message, result }`, а при таймауте — ещё и `unknownDelivery: true`.
|
|
592
649
|
|
|
593
650
|
```js
|
|
594
651
|
const result = await smsClient.sendSms({
|
|
@@ -600,6 +657,19 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
600
657
|
}
|
|
601
658
|
```
|
|
602
659
|
|
|
660
|
+
- **Таймаут и неизвестный статус доставки.** Отправка идёт без повторов с бюджетом 30 секунд. Если SMSGold не ответил, запрос уже ушёл — сообщение **могло быть доставлено**, и повторять отправку нельзя. Такой результат отличается от обычной ошибки: `unknownDelivery: true` и текст «SMSGold не ответил за 30с — статус отправки неизвестен». Дополнительно пакет пишет строку уровня `error` (она уходит в чат B24), потому что решение о повторе должен принимать человек, а не код. Это единственное место, где `Smsgold` пишет в лог сам.
|
|
661
|
+
|
|
662
|
+
```js
|
|
663
|
+
const result = await smsClient.sendSms({ phone, message });
|
|
664
|
+
if (result.unknownDelivery) {
|
|
665
|
+
// Не отправлять повторно: возможен дубль у клиента. Ставим задачу на ручную проверку
|
|
666
|
+
} else if (result.error) {
|
|
667
|
+
// Обычная ошибка — отправка не состоялась, повтор безопасен
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
- **Скачивание файла** по `fileUrl` идёт через `fetchRetry` с 3 попытками и бюджетом 60 секунд на попытку (раньше было 5 попыток без таймаута): пять попыток по минуте задерживали бы саму отправку SMS до пяти минут.
|
|
672
|
+
|
|
603
673
|
### Email
|
|
604
674
|
|
|
605
675
|
- **`Email`** — класс для отправки email через SMTP Яндекса. Экземпляр нужно создать самостоятельно.
|
|
@@ -640,12 +710,15 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
640
710
|
- SMTP-сервер: `smtp.yandex.ru:465` (SMTPS).
|
|
641
711
|
- Копия письма автоматически отправляется на адрес отправителя.
|
|
642
712
|
- Транспорт — `nodemailer` `^9` (диапазон, а не точная версия: иначе патч безопасности нельзя было бы применить на стороне потребителя).
|
|
643
|
-
- Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL.
|
|
713
|
+
- Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL. Скачивание идёт через `fetchWithTimeout` с бюджетом 60 секунд.
|
|
714
|
+
- Таймауты SMTP-транспорта переопределены: соединение 15 с, приветствие 10 с, сокет 120 с. Дефолты nodemailer (2 мин / 30 с / 10 мин) слишком щедры — письмо на Яндекс уходит за секунды, а 10 минут молчания сокета блокируют вызывающую очередь.
|
|
644
715
|
|
|
645
716
|
### Wappi
|
|
646
717
|
|
|
647
718
|
- **`Wappi`** — класс для отправки сообщений через Wappi API (WhatsApp, Telegram, Max). Экземпляр нужно создать самостоятельно.
|
|
648
719
|
|
|
720
|
+
**Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` в чат B24 — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
|
|
721
|
+
|
|
649
722
|
```js
|
|
650
723
|
import { Wappi } from "@andrey4emk/npm-app-back-b24";
|
|
651
724
|
|
|
@@ -720,9 +793,9 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
720
793
|
- Конфигурация хранится в файле `log.json` (через `conf`).
|
|
721
794
|
- Каждый уровень можно включить/выключить, изменить цвет.
|
|
722
795
|
|
|
723
|
-
### fetchRetry
|
|
796
|
+
### fetchRetry и таймауты
|
|
724
797
|
|
|
725
|
-
- **`fetchRetry(url, options?, retries?, delay?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.)
|
|
798
|
+
- **`fetchRetry(url, options?, retries?, delay?, timeoutMs?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.) и собственный таймаут запроса.
|
|
726
799
|
|
|
727
800
|
- **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
|
|
728
801
|
|
|
@@ -769,6 +842,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
769
842
|
| `options` | RequestInit | `undefined` | Параметры fetch (метод, заголовки, body и т.д.) |
|
|
770
843
|
| `retries` | number | `5` | Количество попыток |
|
|
771
844
|
| `delay` | number | `500` | Задержка между попытками (мс) |
|
|
845
|
+
| `timeoutMs` | number | `60000` (или `FETCH_TIMEOUT_MS`) | Бюджет на одну попытку; `0` — без таймаута |
|
|
772
846
|
|
|
773
847
|
- **Возвращает:** `Promise<Response>` — ответ от fetch.
|
|
774
848
|
|
|
@@ -797,6 +871,46 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
797
871
|
);
|
|
798
872
|
```
|
|
799
873
|
|
|
874
|
+
#### Таймауты
|
|
875
|
+
|
|
876
|
+
С версии 3.8.0 каждый запрос пакета имеет бюджет времени: зависшее соединение больше не держится до таймаутов undici (5 минут на попытку, а при медленной «капле» данных — бесконечно).
|
|
877
|
+
|
|
878
|
+
- **Бюджет считается на попытку, а не на весь вызов.** Каждой попытке `fetchRetry` выдаётся свой `AbortSignal.timeout(timeoutMs)`. Бюджет покрывает и чтение тела ответа: сигнал живёт до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` могут упасть с `TimeoutError` уже **после** возврата из `fetchRetry`. Для крупных файлов передавайте больший `timeoutMs` или `0`.
|
|
879
|
+
- **Худший случай по времени** — `retries × (timeoutMs + delay)`. При дефолтах это 5 × (60 000 + 500) ≈ 5 минут.
|
|
880
|
+
- **Свой `options.signal` отключает наш дефолт.** Если вызывающий передал сигнал именно в `options`, а `timeoutMs` не задан, пакет своего таймаута не добавляет — чужой осознанный бюджет не обрезается. Явно переданный `timeoutMs` применяется всегда, тогда побеждает тот сигнал, который сработал раньше. Отмена по чужому сигналу **никогда не повторяется**; различаем её по объекту сигнала, а не по типу ошибки: причиной отмены может быть любой объект, включая `TypeError`.
|
|
881
|
+
- **Сигнал у объекта `Request` на дефолт не влияет.** Он участвует в отмене — сигналы склеиваются через `AbortSignal.any`, иначе `fetch(request, init)` затёр бы его, — но признаком «вызывающий сам управляет временем» не считается: у любого `Request` сигнал есть всегда, даже когда его никто не задавал. Учитывать его значило бы молча снять таймаут со всех вызовов такой формы. Нужен свой бюджет при вызове с `Request` — передавайте `timeoutMs` пятым аргументом.
|
|
882
|
+
- **Таймаут при чтении тела не повторяется и не логируется.** Бюджет действует до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` после возврата из `fetchRetry` могут упасть с `TimeoutError` уже у вас. Функция об этом не знает: повторов там нет, записи в лог тоже. Читаете большое тело — задавайте `timeoutMs` с запасом на скачивание.
|
|
883
|
+
- **Переменная `FETCH_TIMEOUT_MS`** меняет дефолт без правки кода (читается один раз при загрузке модуля), `FETCH_TIMEOUT_MS=0` выключает таймаут. Это аварийный рычаг на случай, когда дефолт обрывает живой долгий запрос — отчёт Seatable, Google Sheets, МойСклад. Действует только на вызовы **без явного** `timeoutMs`: внутренние вызовы пакета (ChatApp, Wappi, SMS, скачивание файлов) передают свои значения из `FETCH_TIMEOUTS` и переменную игнорируют. Значение должно быть целым от 0 до 2 147 483 647 — непригодное отбрасывается с записью в лог, а не выключает защиту молча.
|
|
884
|
+
- **Ограничение при вызове с объектом `Request`:** тело такого запроса читается первой попыткой, и повтор упадёт с `body used already`. Существовало и до таймаутов; для повторяемых запросов передавайте строку URL и `options`.
|
|
885
|
+
|
|
886
|
+
- **`fetchWithTimeout(url, options?, timeoutMs?)`** — один запрос с бюджетом времени, **без повторов и без логирования**. Для неидемпотентных операций: отправка сообщения, создание сущности — там, где повтор мог бы продублировать действие, но защита от зависшего соединения нужна. Правила про чужой сигнал те же, что у `fetchRetry`. Внутри пакета через неё идут все отправки ChatApp, Wappi и SMSGold.
|
|
887
|
+
|
|
888
|
+
```js
|
|
889
|
+
import { fetchWithTimeout, FETCH_TIMEOUTS } from "@andrey4emk/npm-app-back-b24/fetchRetry";
|
|
890
|
+
|
|
891
|
+
const res = await fetchWithTimeout(
|
|
892
|
+
"https://api.example.com/orders",
|
|
893
|
+
{ method: "POST", body: JSON.stringify(order) },
|
|
894
|
+
FETCH_TIMEOUTS.send // 30 000 мс
|
|
895
|
+
);
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
- **`FETCH_TIMEOUTS`** — именованные бюджеты, которыми пакет пользуется сам: `quick` 10 000 (токены), `api` 20 000 (короткое чтение), `send` 30 000 (отправка), `transfer` 60 000 (файлы). **`DEFAULT_FETCH_TIMEOUT_MS`** — действующий дефолт `fetchRetry` (60 000 либо значение `FETCH_TIMEOUT_MS`).
|
|
899
|
+
|
|
900
|
+
- **`isTimeoutError(error)` / `isAbortError(error)`** — различают «не дождались» и «отменили». Ошибка таймаута не оборачивается в свою: наружу идёт исходный `DOMException` с `name: "TimeoutError"` (отмена без причины — `"AbortError"`), проверки читают именно `name`.
|
|
901
|
+
|
|
902
|
+
```js
|
|
903
|
+
import { fetchRetry, isTimeoutError, isAbortError } from "@andrey4emk/npm-app-back-b24/fetchRetry";
|
|
904
|
+
|
|
905
|
+
try {
|
|
906
|
+
await fetchRetry(url, { signal: ac.signal }, 3, 500, 20_000);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
if (isTimeoutError(error)) logs.add("не дождались ответа", "warn");
|
|
909
|
+
else if (isAbortError(error)) logs.add("запрос отменили", "debug");
|
|
910
|
+
else throw error;
|
|
911
|
+
}
|
|
912
|
+
```
|
|
913
|
+
|
|
800
914
|
## Переменные окружения
|
|
801
915
|
|
|
802
916
|
| Переменная | Назначение |
|
|
@@ -807,6 +921,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
807
921
|
| `APP_ENV` | Определяет окружение, используется как ключ секции авторизации |
|
|
808
922
|
| `APP_NAME` | Название приложения (используется в описании задач) |
|
|
809
923
|
| `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
|
|
924
|
+
| `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
|
|
810
925
|
| `CHATAPP_EMAIL` | Email аккаунта ChatApp |
|
|
811
926
|
| `CHATAPP_PASS` | Пароль аккаунта ChatApp |
|
|
812
927
|
| `CHATAPP_APP_ID` | ID приложения в ChatApp |
|
|
@@ -820,6 +935,7 @@ APP_B24_CLIENT_ID=xxx
|
|
|
820
935
|
APP_B24_CLIENT_SECRET=yyy
|
|
821
936
|
APP_NAME=MyApp
|
|
822
937
|
CONFIG_DIR=../config
|
|
938
|
+
FETCH_TIMEOUT_MS=60000
|
|
823
939
|
|
|
824
940
|
CHATAPP_EMAIL=your-email@example.com
|
|
825
941
|
CHATAPP_PASS=your-password
|
package/bitrix24/b24.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
Handler,
|
|
12
12
|
LogRecord,
|
|
13
13
|
Formatter,
|
|
14
|
+
RestrictionParams,
|
|
14
15
|
} from "@bitrix24/b24jssdk";
|
|
15
16
|
import { logs } from "../logs/logs.ts";
|
|
16
17
|
import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
|
|
@@ -114,14 +115,57 @@ const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NE
|
|
|
114
115
|
* REST-методы B24, повтор которых создаёт дубликат сущности или запускает
|
|
115
116
|
* повторное действие: add, create, start, send, uploadfile, import, register.
|
|
116
117
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
118
|
+
* Транспортная ошибка не говорит, дошёл ли запрос до портала: NETWORK_ERROR приходит
|
|
119
|
+
* и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже
|
|
120
|
+
* принял и выполнил запрос. Во втором случае retry создаст вторую задачу, второй файл,
|
|
121
|
+
* второй экземпляр бизнес-процесса. Идемпотентные записи (update, delete, set) повторять
|
|
122
|
+
* безопасно: повторное применение даёт то же состояние.
|
|
123
|
+
*
|
|
124
|
+
* По той же причине у SDK отключены собственные повторы транспортных сбоев
|
|
125
|
+
* (SDK_RESTRICTION_PARAMS) — он этой разницы не знает вовсе.
|
|
122
126
|
*/
|
|
123
127
|
const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.json)?(\?|$)/i;
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Параметры ограничителя SDK. Передаются третьим аргументом конструктора B24OAuth
|
|
131
|
+
* и сливаются там с дефолтами `ParamsFactory.getDefault()`.
|
|
132
|
+
*
|
|
133
|
+
* Единый инвариант: **транспортные сбои SDK не повторяет вообще, повторяет только
|
|
134
|
+
* наш слой**, где работает гейт NON_IDEMPOTENT_METHOD_RE. У SDK нет понятия
|
|
135
|
+
* «создающий вызов»: оборванный crm.deal.add он повторил бы трижды и создал три сделки.
|
|
136
|
+
*
|
|
137
|
+
* Одного `retryOnNetworkError: false` для этого мало. Он добавляет в жёсткий список
|
|
138
|
+
* ровно два кода — NETWORK_ERROR и REQUEST_TIMEOUT, — а SDK конвертирует в них только
|
|
139
|
+
* `ERR_NETWORK` и `ECONNABORTED`. Остальные транспортные ошибки Node доезжают до
|
|
140
|
+
* лимитера под своим кодом (`ECONNRESET` при обрыве сокета, `ERR_BAD_RESPONSE` при
|
|
141
|
+
* 502/504 от шлюза), не находятся ни в жёстком, ни в мягком списке и потому считаются
|
|
142
|
+
* временными — то есть повторяются с backoff. Замерено на локальном сервере, рвущем
|
|
143
|
+
* соединение после приёма запроса: без hardErrorCodes портал выполняет crm.deal.add
|
|
144
|
+
* три раза, с ними — один. На SDK 2.0.0 был один: там транспорт маскировался в
|
|
145
|
+
* JSSDK_UNKNOWN_ERROR, а тот во встроенном жёстком списке.
|
|
146
|
+
*
|
|
147
|
+
* `ERR_NETWORK` и `ECONNABORTED` в списке не нужны: до лимитера они не доживают.
|
|
148
|
+
*
|
|
149
|
+
* Плата за список: `ECONNREFUSED`/`ENOTFOUND`/`EAI_AGAIN` доказывают, что запрос не ушёл,
|
|
150
|
+
* и SDK мог бы повторить их безопасно даже для создающих вызовов. Отказываемся сознательно —
|
|
151
|
+
* ровно так вёл себя 2.0.0, а один понятный инвариант дороже трёх сэкономленных попыток.
|
|
152
|
+
*/
|
|
153
|
+
const SDK_RESTRICTION_PARAMS = {
|
|
154
|
+
retryOnNetworkError: false,
|
|
155
|
+
hardErrorCodes: [
|
|
156
|
+
"ECONNRESET",
|
|
157
|
+
"ECONNREFUSED",
|
|
158
|
+
"ENOTFOUND",
|
|
159
|
+
"ETIMEDOUT",
|
|
160
|
+
"EHOSTUNREACH",
|
|
161
|
+
"ENETUNREACH",
|
|
162
|
+
"EAI_AGAIN",
|
|
163
|
+
"EPIPE",
|
|
164
|
+
"EPROTO",
|
|
165
|
+
"ERR_BAD_RESPONSE",
|
|
166
|
+
],
|
|
167
|
+
} as const satisfies RestrictionParams;
|
|
168
|
+
|
|
125
169
|
const confAuthB24 = new Conf({
|
|
126
170
|
cwd: path.resolve(CONFIG_DIR),
|
|
127
171
|
configName: "authB24",
|
|
@@ -173,10 +217,12 @@ interface V2Envelope {
|
|
|
173
217
|
* Читает сырой конверт ответа из AjaxResult.
|
|
174
218
|
*
|
|
175
219
|
* `getData()` отдаёт замороженную пару `{ result, time }` — полей `next` и `total`
|
|
176
|
-
* в ней нет намеренно (в restApi:v3 их не существует)
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
220
|
+
* в ней нет намеренно (в restApi:v3 их не существует). С версии 2.2.0 SDK снял
|
|
221
|
+
* `isMore()`/`getTotal()`/`getNext()` с удаления и объявил их постоянными читателями
|
|
222
|
+
* конверта restApi:v2, но числового смещения среди них так и нет: `isMore()` отвечает
|
|
223
|
+
* только «есть ли ещё», а `getNext(http)` сам делает следующий запрос, мимо нашего
|
|
224
|
+
* прогресса и retry. Поэтому конверт читаем напрямую: `_data` объявлен `protected`,
|
|
225
|
+
* а не приватным полем класса, поэтому в рантайме доступен.
|
|
180
226
|
*
|
|
181
227
|
* Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
|
|
182
228
|
* она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
|
|
@@ -238,11 +284,22 @@ class B24SdkLogHandler implements Handler {
|
|
|
238
284
|
// нормальный путь через getResultData(): проверка существования в цикле
|
|
239
285
|
// дала бы строку на итерацию. Такие сообщения — уровень debug.
|
|
240
286
|
//
|
|
287
|
+
// 408 и 429 из правила исключены на будущее, сегодня эта ветка недостижима:
|
|
288
|
+
// status попадает в контекст записи уровня WARNING и выше ровно в одном месте
|
|
289
|
+
// SDK — #logNonRetryableClientError, а он вызывается из-под условия, которое
|
|
290
|
+
// 408 и 429 уже исключает. Сообщения лимитера про 429/503 идут другим путём,
|
|
291
|
+
// без status в контексте, и маппятся в warn независимо от этой строки.
|
|
292
|
+
// Условие оставлено потому, что фейлит в безопасную сторону: начни SDK класть
|
|
293
|
+
// status в такие записи — таймаут и упор в лимит будут видны, а не утонут в debug.
|
|
294
|
+
//
|
|
241
295
|
// Остальное: уровень ERROR у SDK — это диагностика отдельной неуспешной
|
|
242
|
-
//
|
|
296
|
+
// попытки. На транспортном сбое их пять — по числу наших попыток, повторы SDK
|
|
297
|
+
// выключены (см. SDK_RESTRICTION_PARAMS). На 429/503 и неизвестных 5xx SDK
|
|
298
|
+
// повторяет по-прежнему, там до пятнадцати: три внутри каждой из наших пяти.
|
|
243
299
|
// Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
|
|
244
|
-
// окончательный провал вызова логирует retry-слой,
|
|
245
|
-
const
|
|
300
|
+
// окончательный провал вызова логирует retry-слой, ровно один раз.
|
|
301
|
+
const isQuietClientError = status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
302
|
+
const level = isQuietClientError ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
|
|
246
303
|
|
|
247
304
|
// Контекст SDK (requestId, method, code, wait, status) уже прогнан
|
|
248
305
|
// через redactSensitiveParams — токены в него не попадают
|
|
@@ -303,7 +360,13 @@ function createB24Instance(): B24OAuth | null {
|
|
|
303
360
|
|
|
304
361
|
const secret: B24OAuthSecret = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET };
|
|
305
362
|
|
|
306
|
-
|
|
363
|
+
// Параметры ограничителя задаём третьим аргументом конструктора: SDK сливает их
|
|
364
|
+
// с дефолтами (`{ ...ParamsFactory.getDefault(), ...restrictionParams }` в AbstractHttp)
|
|
365
|
+
// и передаёт обоим http-клиентам, v2 и v3, ещё до того как экземпляр можно использовать.
|
|
366
|
+
// Через setRestrictionManagerParams() было бы окно между созданием и настройкой,
|
|
367
|
+
// а результат вызова к тому же непроверяем: он завершается Promise.allSettled и
|
|
368
|
+
// не отклоняется никогда.
|
|
369
|
+
const b24 = new B24OAuth(authParams, secret, { restrictionParams: SDK_RESTRICTION_PARAMS });
|
|
307
370
|
|
|
308
371
|
// Диагностика SDK от WARNING и выше уходит в logs пакета: предупреждения
|
|
309
372
|
// callList/fetchList про игнорируемый order и остановку пагинации, сообщения лимитера.
|
|
@@ -491,9 +554,12 @@ export function stopProactiveRefresh(): void {
|
|
|
491
554
|
/**
|
|
492
555
|
* Проверяет, является ли ошибка из SDK сетевой.
|
|
493
556
|
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
557
|
+
* С версии 2.2.0 SDK отдаёт транспортный сбой честным кодом: NETWORK_ERROR (status 0)
|
|
558
|
+
* или REQUEST_TIMEOUT (status 408). До 2.2.0 он их маскировал — в логирующей ветке падал
|
|
559
|
+
* собственный TypeError, и наружу приходил AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0.
|
|
560
|
+
* Проверку по status 0 оставляем: она ловит всё, что SDK отдаёт без внятного кода.
|
|
561
|
+
* Сюда же попадает провал обновления токена внутри callMethod: SDK перезаворачивает
|
|
562
|
+
* SdkError в AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0, и исходный код теряется.
|
|
497
563
|
* RefreshTokenError приходит как SdkError с кодом вроде ENOTFOUND и без originalError.
|
|
498
564
|
*/
|
|
499
565
|
function isB24NetworkError(error: unknown): boolean {
|
|
@@ -511,6 +577,18 @@ function isB24NetworkError(error: unknown): boolean {
|
|
|
511
577
|
if (originalError && isNetworkError(originalError)) return true;
|
|
512
578
|
if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
|
|
513
579
|
if (status === 0) return true;
|
|
580
|
+
|
|
581
|
+
// 502 Bad Gateway: шлюз перед порталом не смог получить ответ от upstream —
|
|
582
|
+
// запрос почти наверняка не выполнялся, повтор осмыслен. Мы держим этот код
|
|
583
|
+
// в SDK_RESTRICTION_PARAMS.hardErrorCodes, чтобы SDK его не повторял, но
|
|
584
|
+
// повторить его должен наш слой: там создающие вызовы отсекает гейт.
|
|
585
|
+
//
|
|
586
|
+
// 504 сюда намеренно не входит, хотя приходит тем же кодом ERR_BAD_RESPONSE:
|
|
587
|
+
// это «портал взял запрос и считает прямо сейчас, шлюз устал ждать». Повтор
|
|
588
|
+
// создающего вызова дал бы дубликат, а читающего — ничего: за таймаут шлюза
|
|
589
|
+
// запрос не уложился один раз, не уложится и на второй, зато пять тяжёлых
|
|
590
|
+
// повторов добавят нагрузки порталу ровно тогда, когда ему и так плохо.
|
|
591
|
+
if (status === 502) return true;
|
|
514
592
|
}
|
|
515
593
|
|
|
516
594
|
return isNetworkError(error);
|
|
@@ -695,8 +773,10 @@ function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any>
|
|
|
695
773
|
const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
|
|
696
774
|
const merged: TypeCallParams = { ...params };
|
|
697
775
|
|
|
698
|
-
// Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod
|
|
699
|
-
|
|
776
|
+
// Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod.
|
|
777
|
+
// typeof, а не только Number.isInteger: последний ничего не сужает, и при
|
|
778
|
+
// exactOptionalPropertyTypes у потребителя присваивание number | undefined не проходит
|
|
779
|
+
if (!("start" in merged && Number.isInteger(merged.start)) && typeof start === "number" && Number.isInteger(start)) {
|
|
700
780
|
merged.start = start;
|
|
701
781
|
}
|
|
702
782
|
|
|
@@ -790,11 +870,14 @@ function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any>
|
|
|
790
870
|
};
|
|
791
871
|
|
|
792
872
|
async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
|
|
873
|
+
// Ключи со значением undefined не подставляем вовсе: при exactOptionalPropertyTypes
|
|
874
|
+
// у потребителя явный undefined не подходит опциональному полю опций SDK.
|
|
875
|
+
// Поведение прежнее — null и undefined одинаково означают «ключ не передан»
|
|
793
876
|
yield* v2().fetchList.make<any>({
|
|
794
877
|
method,
|
|
795
878
|
params,
|
|
796
|
-
idKey,
|
|
797
|
-
customKeyForResult
|
|
879
|
+
...(idKey === undefined ? {} : { idKey }),
|
|
880
|
+
...(customKeyForResult === null || customKeyForResult === undefined ? {} : { customKeyForResult }),
|
|
798
881
|
});
|
|
799
882
|
}
|
|
800
883
|
|