@andrey4emk/npm-app-back-b24 3.6.1 → 3.7.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 CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  reinitializeB24,
55
55
  stopProactiveRefresh,
56
56
  getResultData,
57
+ callProtected,
57
58
  errorB24,
58
59
  event,
59
60
  Event,
@@ -122,7 +123,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
122
123
 
123
124
  Модуль работает с токенами через файл `authB24.json` (используется библиотека `conf`). Модель БД не требуется.
124
125
 
125
- - **`$b24`** — готовый экземпляр `B24OAuth`, создаётся автоматически при импорте на основе сохранённых токенов. Если данные авторизации отсутствуют — `null`.
126
+ - **`$b24`** — готовый экземпляр `B24Client` (`B24OAuth` плюс методы фасада, см. ниже), создаётся автоматически при импорте на основе сохранённых токенов. Если данные авторизации отсутствуют — `null`.
126
127
 
127
128
  ```js
128
129
  import { $b24 } from "@andrey4emk/npm-app-back-b24";
@@ -137,9 +138,19 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
137
138
  - Окружение (`DEV`/`PROD`) определяется переменной `APP_ENV` — используется как ключ секции в конфиге авторизации.
138
139
  - Признак того, что проект использует OAuth, — заданные `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET`. Если их нет, `$b24` молча становится `null` (уровень `debug`). Если они заданы, но токенов в `authB24.json` не хватает, пишется `error` — это уже настоящая проблема конфигурации.
139
140
 
141
+ **Методы `callMethod`, `callListMethod`, `fetchListMethod`, `callBatch`, `callBatchByChunk` — фасад пакета.**
142
+
143
+ SDK 2.x помечает эти пять методов к удалению в следующем major и на каждый вызов пишет предупреждение об устаревании. Пакет реализует их сам поверх `actions.v2.*`, поэтому вызовы у потребителей не меняются, а зависимости от удаляемого API у пакета больше нет. Сигнатуры и типы возврата прежние — правок в проектах не требуется.
144
+
145
+ Поведение прежнее у четырёх методов из пяти. **`callListMethod` отличается в двух местах:** он бросает внятную ошибку с именем метода там, где SDK падал `TypeError` из своих недр (портал вернул «мягкую» ошибку, ответ не список, конверт нечитаем, `next` не растёт), и у него есть потолок в 1000 страниц — при упоре в него вызов тоже бросает, а не отдаёт молча обрезанный список. Если у вас есть `try`/`catch` вокруг постраничных выборок, текст ошибки изменится; если его нет — падение станет заметнее, но не появится там, где раньше всё работало.
146
+
147
+ Прямой доступ к `$b24.actions.*` остаётся: это сырой SDK **без** retry и гейта идемпотентности. Если защита нужна, оборачивайте вызов в `callProtected()`.
148
+
140
149
  **Retry при сетевых ошибках:**
141
150
 
142
- Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`). Метод `fetchListMethod` из ретраев исключён — это async-генератор, оборачивать его в async-функцию нельзя; постраничные запросы SDK ретраит сам.
151
+ Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`). Метод `fetchListMethod` из ретраев исключён — это async-генератор, оборачивать его в async-функцию нельзя. `callBatchByChunk` пакет реализует, но не ретраит.
152
+
153
+ `callListMethod` повторяется целиком: при сетевом сбое пагинация начинается с нулевой страницы заново. Постраничный retry был бы сменой семантики, поэтому поведение оставлено прежним.
143
154
 
144
155
  | Параметр | Значение |
145
156
  | -------------- | ----------------------------------------- |
@@ -160,6 +171,10 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
160
171
 
161
172
  У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
162
173
 
174
+ **Диагностика SDK.** Начиная с 3.7.0 собственные сообщения SDK от уровня `WARNING` и выше попадают в лог пакета с префиксом `SDK B24:` вместе с контекстом (`requestId`, `method`, `status`, код ошибки) — например предупреждение `fetchList` про игнорируемый `order` или про остановку пагинации, когда `idKey` не совпал с полем ответа. Порог нужен: на уровне `info` SDK пишет две строки на каждый запрос. Ошибки SDK приходят как `warn`, а не `error` — это диагностика отдельной неуспешной попытки, а окончательный провал вызова один раз пишет retry-слой.
175
+
176
+ Ответы со статусом 4xx уходят на `debug`: лимитер SDK пишет через свой `error()` любой 4xx кроме 408 и 429, а Bitrix24 отдаёт 400 на штатные «мягкие» ошибки вроде несуществующей сущности. Без этого проверка существования в цикле давала бы строку на каждой итерации. Разбирайте такие ответы через `getResultData()` — это нормальный путь, а не сбой.
177
+
163
178
  **«Мягкие» ошибки Bitrix24:**
164
179
 
165
180
  Часть ошибок (`ERROR_ENTITY_NOT_FOUND`, `BITRIX_REST_V3_EXCEPTION_*`) SDK не бросает исключением, а возвращает как `AjaxResult` с ошибкой: `isSuccess === false`, и тогда `response.getData()` вернёт `undefined`. Отдельный случай — успешный ответ, у которого сам `result` пустой (`null`/`undefined`). Чтобы не падать на `undefined`, разбирайте ответы через `getResultData()` — он покрывает обе ситуации и бросает понятную ошибку с именем метода.
@@ -244,6 +259,40 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
244
259
  const deal = getResultData(response, "crm.deal.get");
245
260
  ```
246
261
 
262
+ - **`callProtected(run, methodName)`** — прогоняет произвольный вызов `$b24.actions.*` через тот же retry и гейт идемпотентности, что и методы фасада. Нужен, когда требуется API, которого у фасада нет: `FilterV3`, keyset-пагинация (`callTail`/`fetchTail`), `aggregate`.
263
+
264
+ - **Параметры:**
265
+ - `run` (`() => Promise<T>`) — сам вызов.
266
+ - `methodName` (`string | string[]`) — имя REST-метода B24 (**не** метода SDK) либо список имён, если внутри batch: по ним гейт решает, создаёт ли вызов сущность.
267
+ - **Возвращает:** промис с результатом `run`.
268
+
269
+ ```js
270
+ import { $b24, callProtected } from "@andrey4emk/npm-app-back-b24";
271
+
272
+ const response = await callProtected(
273
+ () => $b24.actions.v3.call.make({ method: "crm.item.list", params }),
274
+ "crm.item.list"
275
+ );
276
+
277
+ // Для batch перечисляйте методы команд, а не "batch"
278
+ const batchResponse = await callProtected(
279
+ () => $b24.actions.v3.batch.make({ calls }),
280
+ ["crm.item.get", "crm.item.add"]
281
+ );
282
+ ```
283
+
284
+ **Гейт работает fail-closed.** Если список имён пуст или хоть одно имя не похоже на REST-метод (легальные всегда с точкой — `crm.deal.add`, `disk.folder.uploadfile`), вызов считается создающим и не повторяется. Поэтому `callProtected(() => batch.make({ calls }), "batch")` защиту не обойдёт: имя `batch` не разбирается, и батч с `crm.deal.add` внутри не будет повторён пять раз при `status: 0`. Цена ошибки в эту сторону — лишняя необработанная сетевая ошибка; в обратную — дубликаты сущностей.
285
+
286
+ Без обёртки вызов `$b24.actions.*` идёт мимо защиты: ни повторов при сетевых сбоях, ни блокировки повтора для создающих методов.
287
+
288
+ - **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`.
289
+
290
+ ```ts
291
+ import type { B24Client } from "@andrey4emk/npm-app-back-b24";
292
+
293
+ function sync(client: B24Client) { /* ... */ }
294
+ ```
295
+
247
296
  ### Задачи ошибок
248
297
 
249
298
  - **`errorB24(dataTask)`** — создаёт служебную задачу в Bitrix24 при ошибках/событиях. Использует глобальный `$b24`.
package/bitrix24/b24.ts CHANGED
@@ -1,5 +1,17 @@
1
- import { B24OAuth, AjaxError, SdkError, Logger } from "@bitrix24/b24jssdk";
2
- import type { B24OAuthParams, B24OAuthSecret, AuthData, AjaxResult } from "@bitrix24/b24jssdk";
1
+ import { B24OAuth, AjaxError, SdkError, Logger, LogLevel, Result } from "@bitrix24/b24jssdk";
2
+ import type {
3
+ B24OAuthParams,
4
+ B24OAuthSecret,
5
+ AuthData,
6
+ AjaxResult,
7
+ TypeCallParams,
8
+ BatchCommandsArrayUniversal,
9
+ BatchCommandsObjectUniversal,
10
+ BatchNamedCommandsUniversal,
11
+ Handler,
12
+ LogRecord,
13
+ Formatter,
14
+ } from "@bitrix24/b24jssdk";
3
15
  import { logs } from "../logs/logs.ts";
4
16
  import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
5
17
  import Conf from "conf";
@@ -17,6 +29,49 @@ interface SaveResult {
17
29
  message: string;
18
30
  }
19
31
 
32
+ /**
33
+ * `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
34
+ *
35
+ * SDK 2.x помечает `callMethod`/`callListMethod`/`fetchListMethod`/`callBatch`/`callBatchByChunk`
36
+ * к удалению в следующем major. Пакет предоставляет их сам, поэтому сигнатуры для потребителя
37
+ * не меняются, а зависимости от удаляемого API у нас больше нет.
38
+ *
39
+ * Тип расширяет `B24OAuth`, поэтому `$b24` по-прежнему передаётся туда, где ждут `B24OAuth`
40
+ * (`new Smsgold(auth, $b24)`, `new Event($b24)`).
41
+ */
42
+ export interface B24Client extends B24OAuth {
43
+ callMethod<T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>>;
44
+ callListMethod(method: string, params?: object, progress?: null | ((progress: number) => void), customKeyForResult?: string | null): Promise<Result>;
45
+ fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]>;
46
+ callBatch(calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result>;
47
+ callBatchByChunk(calls: Array<any>, isHaltOnError: boolean): Promise<Result>;
48
+ }
49
+
50
+ /**
51
+ * Минимальный контракт «умеет вызывать REST-методы B24».
52
+ *
53
+ * Нужен внутренним классам (`Event`, `Smsgold`), которые принимают экземпляр извне.
54
+ * Объявляет `callMethod` сам, поэтому переживёт удаление метода из `B24OAuth`.
55
+ *
56
+ * Объединение `B24OAuth | B24Client` эту задачу не решает: `B24Client` расширяет
57
+ * `B24OAuth`, union схлопывается в супертип, а вызов метода на union требует его
58
+ * наличия в обеих ветках — то есть после удаления из SDK тайпчек сломается всё равно.
59
+ *
60
+ * Структурный тип шире `B24OAuth`, поэтому публичные сигнатуры конструкторов
61
+ * не сужаются: и `B24OAuth`, и `$b24`, и любой свой объект с `callMethod` подходят.
62
+ */
63
+ export interface B24MethodCaller {
64
+ // Дженерика здесь быть не должно: у SDK метод не generic, и `B24OAuth`
65
+ // потребителя перестал бы подходить под этот тип. Проверено тайпчеком
66
+ callMethod(method: string, params?: object, start?: number): Promise<AjaxResult>;
67
+ }
68
+
69
+ /** Набор методов фасада — то, что Proxy отдаёт вместо реализаций SDK */
70
+ type FacadeMethods = Pick<B24Client, "callMethod" | "callListMethod" | "fetchListMethod" | "callBatch" | "callBatchByChunk">;
71
+
72
+ /** Формы списка команд batch, которые принимает actions.v2 */
73
+ type BatchCalls = BatchCommandsArrayUniversal | BatchCommandsObjectUniversal | BatchNamedCommandsUniversal;
74
+
20
75
  // ==================== Константы ====================
21
76
 
22
77
  const CONFIG_DIR = process.env.CONFIG_DIR || "../config";
@@ -34,9 +89,21 @@ const RETRY_COUNT = 5;
34
89
  /** Задержка между попытками (мс) */
35
90
  const RETRY_DELAY_MS = 500;
36
91
 
37
- /** Методы B24OAuth, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
92
+ /** Методы фасада, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
38
93
  const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
39
94
 
95
+ /**
96
+ * Имена, которые Proxy резолвит в собственную реализацию пакета поверх `actions.v2.*`.
97
+ *
98
+ * Шире, чем RETRYABLE_METHODS: `fetchListMethod` и `callBatchByChunk` мы реализуем,
99
+ * но не ретраим. `callBatchByChunk` в наборе обязателен — пока хоть один deprecated-метод
100
+ * SDK достижим через `$b24`, он будет писать предупреждение об устаревании.
101
+ */
102
+ const FACADE_METHODS = new Set(["callMethod", "callListMethod", "fetchListMethod", "callBatch", "callBatchByChunk"]);
103
+
104
+ /** Потолок числа страниц в callListMethod — страховка от бесконечной пагинации */
105
+ const LIST_MAX_PAGES = 1000;
106
+
40
107
  /**
41
108
  * Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
42
109
  * и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
@@ -95,6 +162,102 @@ export function getResultData<T = any>(response: AjaxResult, methodName: string)
95
162
  return data.result as T;
96
163
  }
97
164
 
165
+ /** Конверт ответа restApi:v2 — то, что прислал портал, до того как AjaxResult его урезал */
166
+ interface V2Envelope {
167
+ result?: unknown;
168
+ next?: unknown;
169
+ total?: unknown;
170
+ }
171
+
172
+ /**
173
+ * Читает сырой конверт ответа из AjaxResult.
174
+ *
175
+ * `getData()` отдаёт замороженную пару `{ result, time }` — полей `next` и `total`
176
+ * в ней нет намеренно (в restApi:v3 их не существует), а `isMore()`/`getTotal()`/`getNext()`
177
+ * помечены `@removed 2.0.0` и читают тот же приватный конверт. Единственный способ узнать
178
+ * смещение следующей страницы, не опираясь на удаляемое API, — прочитать конверт напрямую:
179
+ * `_data` объявлен `protected`, а не приватным полем класса, поэтому в рантайме доступен.
180
+ *
181
+ * Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
182
+ * она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
183
+ */
184
+ function readV2Envelope(response: AjaxResult, method: string): V2Envelope {
185
+ const envelope = (response as unknown as { _data?: unknown })._data;
186
+
187
+ if (!envelope || typeof envelope !== "object") {
188
+ throw new Error(`${method}: не удалось прочитать конверт ответа Bitrix24 (AjaxResult._data недоступен) — изменился внутренний формат SDK`);
189
+ }
190
+
191
+ return envelope as V2Envelope;
192
+ }
193
+
194
+ // ==================== Логгер SDK ====================
195
+
196
+ /**
197
+ * Мост из логгера SDK в `logs` пакета.
198
+ *
199
+ * Порог WARNING обязателен: SDK пишет `post/send` и `post/response` на уровне `info`
200
+ * на каждый запрос и `http batch request starting/completed` на `debug` — без фильтра
201
+ * это залило бы лог.
202
+ *
203
+ * `AbstractHandler` объявлен в типах SDK, но в рантайме не экспортируется,
204
+ * поэтому реализуем интерфейс `Handler` обычным классом.
205
+ */
206
+ class B24SdkLogHandler implements Handler {
207
+ private formatter: Formatter | null = null;
208
+
209
+ isHandling(level: LogLevel): boolean {
210
+ return level >= LogLevel.WARNING;
211
+ }
212
+
213
+ shouldBubble(): boolean {
214
+ return true;
215
+ }
216
+
217
+ setFormatter(formatter: Formatter): void {
218
+ this.formatter = formatter;
219
+ }
220
+
221
+ getFormatter(): Formatter | null {
222
+ return this.formatter;
223
+ }
224
+
225
+ async handle(record: LogRecord): Promise<boolean> {
226
+ // Бросать отсюда нельзя ни при каких обстоятельствах: Logger.log() делает
227
+ // await handle(), но лимитер зовёт логгер без await — исключение стало бы
228
+ // unhandled rejection и на дефолтных настройках Node убило бы процесс.
229
+ // Путь к броску реален: logs.add() читает conf, а тот перечитывает файл
230
+ // на каждом обращении и падает на битом log.json. В catch пишем через
231
+ // console.error, а не через logs — иначе рискуем зациклиться на той же ошибке.
232
+ try {
233
+ const status = Number(record.context?.status);
234
+
235
+ // Лимитер SDK через error() пишет любой 4xx кроме 408/429 как
236
+ // «non-retryable client error». Портал отдаёт 400 на штатные «мягкие»
237
+ // ошибки (несуществующая сущность), которые README описывает как
238
+ // нормальный путь через getResultData(): проверка существования в цикле
239
+ // дала бы строку на итерацию. Такие сообщения — уровень debug.
240
+ //
241
+ // Остальное: уровень ERROR у SDK — это диагностика отдельной неуспешной
242
+ // попытки, а таких попыток на один вызов до пятнадцати (3 внутри SDK × 5 наших).
243
+ // Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
244
+ // окончательный провал вызова логирует retry-слой, и он туда попадёт ровно один раз.
245
+ const level = status >= 400 && status < 500 ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
246
+
247
+ // Контекст SDK (requestId, method, code, wait, status) уже прогнан
248
+ // через redactSensitiveParams — токены в него не попадают
249
+ const context = record.context && Object.keys(record.context).length > 0 ? record.context : undefined;
250
+
251
+ logs.add(`SDK B24: ${record.message}`, level, context);
252
+ } catch (error: unknown) {
253
+ const msg = error instanceof Error ? error.message : String(error);
254
+ console.error(`B24SdkLogHandler: не удалось записать сообщение SDK — ${msg}`);
255
+ }
256
+
257
+ return true;
258
+ }
259
+ }
260
+
98
261
  // ==================== Создание экземпляра B24OAuth ====================
99
262
 
100
263
  function createB24Instance(): B24OAuth | null {
@@ -142,10 +305,11 @@ function createB24Instance(): B24OAuth | null {
142
305
 
143
306
  const b24 = new B24OAuth(authParams, secret);
144
307
 
145
- // Логгер без хендлеров: SDK 2.x на каждый вызов callMethod/callBatch пишет
146
- // предупреждение об устаревании напрямую в console.warn, если логгер — NullLogger.
147
- // Любой другой логгер получает запись через свой интерфейс, поэтому консоль остаётся чистой.
148
- b24.setLogger(Logger.create("npm-app-back-b24"));
308
+ // Диагностика SDK от WARNING и выше уходит в logs пакета: предупреждения
309
+ // callList/fetchList про игнорируемый order и остановку пагинации, сообщения лимитера.
310
+ // Дефолтный NullLogger не годится LoggerFactory.forcedLog пишет мимо логгера
311
+ // прямо в console.warn, но после перехода на фасад этот путь у нас недостижим.
312
+ b24.setLogger(Logger.create("npm-app-back-b24").pushHandler(new B24SdkLogHandler()));
149
313
 
150
314
  return b24;
151
315
  }
@@ -415,71 +579,277 @@ function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
415
579
  return blocking.length > 0 ? blocking.join(", ") : null;
416
580
  }
417
581
 
582
+ /**
583
+ * Общий цикл повторов при сетевых ошибках.
584
+ *
585
+ * @param fn — вызов без аргументов, уже замкнутый на нужные параметры
586
+ * @param label — префикс лог-строк, по нему в логе видно источник повтора
587
+ * @param blockReason — причина, по которой повтор запрещён (создающий вызов), либо null
588
+ */
589
+ async function runWithRetry<T>(fn: () => Promise<T>, label: string, blockReason: string | null): Promise<T> {
590
+ let lastError: unknown;
591
+
592
+ for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
593
+ try {
594
+ return await fn();
595
+ } catch (error: unknown) {
596
+ lastError = error;
597
+
598
+ // Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
599
+ // вызывающему коду как есть, он решает, что с ней делать
600
+ if (!isB24NetworkError(error)) throw error;
601
+
602
+ const msg = error instanceof Error ? error.message : String(error);
603
+ const code = error instanceof SdkError ? ` [${error.code}]` : "";
604
+
605
+ // Окончательные отказы логируем на error: собственные модули пакета
606
+ // (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
607
+ // но не пишут в лог — без этих строк сбой $b24 не виден нигде
608
+
609
+ if (attempt === RETRY_COUNT) {
610
+ logs.add(`${label}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
611
+ throw error;
612
+ }
613
+
614
+ // Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
615
+ // а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
616
+ // не повторяем — лучше вернуть ошибку, чем создать дубликат
617
+ if (blockReason) {
618
+ logs.add(`${label}${code}: повтор отменён, вызов создаёт сущности (${blockReason}) — ${msg}`, "error");
619
+ throw error;
620
+ }
621
+
622
+ // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
623
+ // иначе один упавший вызов дал бы пять сообщений
624
+ logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
625
+
626
+ await delay(RETRY_DELAY_MS);
627
+ }
628
+ }
629
+
630
+ // Недостижимо при RETRY_COUNT >= 1, нужно для TypeScript
631
+ throw lastError;
632
+ }
633
+
418
634
  /** Оборачивает async-функцию retry-логикой при сетевых ошибках */
419
635
  function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
420
636
  return (async (...args: any[]) => {
421
637
  // Состав вызова между попытками не меняется — разбираем один раз
422
638
  const blockReason = getRetryBlockReason(methodName, args);
423
- let lastError: unknown;
639
+ return runWithRetry(() => fn.apply(context, args), `$b24.${methodName}`, blockReason);
640
+ }) as T;
641
+ }
424
642
 
425
- for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
426
- try {
427
- return await fn.apply(context, args);
428
- } catch (error: unknown) {
429
- lastError = error;
643
+ /**
644
+ * Прогоняет произвольный вызов `actions.*` через тот же retry и гейт идемпотентности,
645
+ * что и методы фасада. Прямой `$b24.actions.v3.call.make()` идёт мимо защиты — если она
646
+ * нужна (например, ради `FilterV3` или keyset-пагинации), вызов оборачивают этой функцией.
647
+ *
648
+ * @param run — сам вызов, например `() => $b24.actions.v3.call.make({ method, params })`
649
+ * @param methodName — имя REST-метода B24 (не метода SDK) либо список имён, если внутри
650
+ * batch: по ним гейт решает, создаёт ли вызов сущность
651
+ *
652
+ * @example
653
+ * const response = await callProtected(
654
+ * () => $b24!.actions.v3.call.make({ method: "crm.item.list", params }),
655
+ * "crm.item.list"
656
+ * );
657
+ *
658
+ * @example
659
+ * const response = await callProtected(
660
+ * () => $b24!.actions.v3.batch.make({ calls }),
661
+ * ["crm.item.get", "crm.item.add"]
662
+ * );
663
+ */
664
+ export async function callProtected<T>(run: () => Promise<T>, methodName: string | string[]): Promise<T> {
665
+ const names = (Array.isArray(methodName) ? methodName : [methodName]).map((name) => String(name).trim()).filter((name) => name.length > 0);
430
666
 
431
- // Ошибка не сетевая (бизнес-логика B24, неверные параметры) отдаём
432
- // вызывающему коду как есть, он решает, что с ней делать
433
- if (!isB24NetworkError(error)) throw error;
667
+ // Fail-closed, как в гейте callBatch: список пуст или имя не похоже на REST-метод
668
+ // (легальные имена всегда с точкой crm.deal.add, disk.folder.uploadfile) считаем
669
+ // вызов создающим. Иначе callProtected(() => batch.make({ calls }), "batch") прошёл бы
670
+ // мимо гейта, и батч с crm.deal.add внутри повторился бы до пяти раз при status 0.
671
+ const isUnparsed = names.length === 0 || names.some((name) => !name.includes("."));
672
+ const blocking = names.filter((name) => NON_IDEMPOTENT_METHOD_RE.test(name));
434
673
 
435
- const msg = error instanceof Error ? error.message : String(error);
436
- const code = error instanceof SdkError ? ` [${error.code}]` : "";
674
+ const blockReason = isUnparsed ? "имя метода не разобрано" : blocking.length > 0 ? blocking.join(", ") : null;
437
675
 
438
- // Окончательные отказы логируем на error: собственные модули пакета
439
- // (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
440
- // но не пишут в лог — без этих строк сбой $b24 не виден нигде
676
+ return runWithRetry(run, `actions:${names.join(", ") || "имя не указано"}`, blockReason);
677
+ }
441
678
 
442
- if (attempt === RETRY_COUNT) {
443
- logs.add(`$b24.${methodName}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
444
- throw error;
445
- }
679
+ // ==================== Фасад поверх actions.v2.* ====================
446
680
 
447
- // Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
448
- // а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
449
- // не повторяем лучше вернуть ошибку, чем создать дубликат
450
- if (blockReason) {
451
- logs.add(`$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${blockReason}) ${msg}`, "error");
452
- throw error;
453
- }
681
+ /**
682
+ * Собственные реализации пяти методов, которые SDK 2.x помечает к удалению.
683
+ * Повторяют `AbstractB24` дословно, включая дефолты и порядок аргументов.
684
+ *
685
+ * Позиционные сигнатуры сохранены один в один: на этом держится то, что гейт
686
+ * идемпотентности (`getRetryBlockReason`) видит те же аргументы, что и раньше,
687
+ * — retry оборачивает фасад снаружи, а преобразование в объект опций
688
+ * происходит уже внутри.
689
+ */
690
+ function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any> {
691
+ // Геттер actions при каждом обращении проверяет инициализацию экземпляра —
692
+ // читаем его в момент вызова, а не один раз при создании фасада
693
+ const v2 = () => target.actions.v2;
694
+
695
+ const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
696
+ const merged: TypeCallParams = { ...params };
697
+
698
+ // Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod
699
+ if (!("start" in merged && Number.isInteger(merged.start)) && Number.isInteger(start)) {
700
+ merged.start = start;
701
+ }
702
+
703
+ return v2().call.make<T>({ method, params: merged });
704
+ };
705
+
706
+ /**
707
+ * Собственный офсетный цикл, а не `actions.v2.callList.make`.
708
+ *
709
+ * У `callList.make` keyset-пагинация: он шлёт `start: -1`, навязывает
710
+ * `order: { ID: 'ASC' }` и добавляет в фильтр `>ID`. Пользовательский `order`
711
+ * при этом молча игнорируется, а колбэка `progress` там нет вовсе. Делегирование
712
+ * туда изменило бы поведение постраничных выборок у потребителей.
713
+ */
714
+ const callListMethod = async (
715
+ method: string,
716
+ params?: object,
717
+ progress?: null | ((progress: number) => void),
718
+ customKeyForResult?: string | null
719
+ ): Promise<Result> => {
720
+ const result = new Result();
721
+ const onProgress = typeof progress === "function" ? progress : null;
722
+
723
+ onProgress?.(0);
724
+
725
+ const list: unknown[] = [];
726
+ let start = 0;
727
+ let completed = false;
728
+
729
+ for (let page = 1; page <= LIST_MAX_PAGES; page++) {
730
+ const response = await v2().call.make({ method, params: { ...params, start } });
731
+
732
+ // SDK в своём callListMethod этой проверки не делает и падает TypeError
733
+ // на getData().result, когда портал вернул «мягкую» ошибку
734
+ if (!response.isSuccess) {
735
+ throw new Error(`${method}: Bitrix24 вернул ошибку — ${response.getErrorMessages().join("; ")}`);
736
+ }
737
+
738
+ // Читаем конверт целиком: getData() режет ответ до { result, time },
739
+ // а пагинация держится на next, которого там нет
740
+ const envelope = readV2Envelope(response, method);
741
+ const payload = envelope.result;
742
+ const chunk = customKeyForResult ? (payload as Record<string, unknown> | undefined)?.[customKeyForResult] : payload;
743
+
744
+ if (!Array.isArray(chunk)) {
745
+ throw new Error(`${method}: ответ не является списком`);
746
+ }
747
+
748
+ for (const item of chunk) {
749
+ list.push(item);
750
+ }
751
+
752
+ // Единственное правило остановки — отсутствие next в конверте. Считать
753
+ // смещение самим (start += chunk.length) нельзя: портал ставит next = start + 50
754
+ // независимо от того, сколько строк реально отдал, и на странице, укороченной
755
+ // правами доступа или фильтром, собственный счётчик отстаёт — перекрытие
756
+ // читается второй раз и записи дублируются. Метод, который игнорирует start
757
+ // и отдаёт весь список одной страницей, next не присылает и останавливается здесь же.
758
+ if (!Number.isInteger(envelope.next)) {
759
+ completed = true;
760
+ break;
761
+ }
454
762
 
455
- // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
456
- // иначе один упавший вызов дал бы пять сообщений
457
- logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
763
+ const nextStart = Number(envelope.next);
458
764
 
459
- await delay(RETRY_DELAY_MS);
765
+ // next обязан расти. Портал, вернувший прежнее или меньшее смещение,
766
+ // иначе гонял бы одну и ту же страницу до потолка, раздувая список
767
+ if (nextStart <= start) {
768
+ throw new Error(`${method}: портал вернул непродвигающийся next (${nextStart}) при start ${start} — пагинация зациклилась`);
769
+ }
770
+
771
+ start = nextStart;
772
+
773
+ if (onProgress) {
774
+ const total = Number(envelope.total) || 0;
775
+ onProgress(total > 0 ? Math.round((100 * list.length) / total) : 100);
460
776
  }
461
777
  }
462
778
 
463
- // Недостижимо при RETRY_COUNT >= 1, нужно для TypeScript
464
- throw lastError;
465
- }) as T;
779
+ // Обрезанный список, отданный как успешный, худший исход: потребитель
780
+ // примет неполную выборку за полную. Поэтому бросаем, а не логируем:
781
+ // addError() на базовом Result бесполезен — getData() отдаёт данные
782
+ // независимо от наличия ошибок, и существующие потребители её не увидят
783
+ if (!completed) {
784
+ throw new Error(`${method}: достигнут потолок в ${LIST_MAX_PAGES} страниц, портал продолжает отдавать next — выборка прервана как неполная`);
785
+ }
786
+
787
+ onProgress?.(100);
788
+ result.setData(list);
789
+ return result;
790
+ };
791
+
792
+ async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
793
+ yield* v2().fetchList.make<any>({
794
+ method,
795
+ params,
796
+ idKey,
797
+ customKeyForResult: customKeyForResult === null ? undefined : customKeyForResult,
798
+ });
799
+ }
800
+
801
+ const callBatch = async (calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result> => {
802
+ // Дефолты подставляем мы: batch.make своих не имеет, он просто
803
+ // расширяет переданный объект опций версией API
804
+ return v2().batch.make({
805
+ calls: calls as BatchCalls,
806
+ options: {
807
+ isHaltOnError: isHaltOnError ?? true,
808
+ returnAjaxResult: returnAjaxResult ?? false,
809
+ },
810
+ });
811
+ };
812
+
813
+ const callBatchByChunk = async (calls: Array<any>, isHaltOnError: boolean): Promise<Result> => {
814
+ // isHaltOnError передаём как есть, без ?? true — дословно по AbstractB24.
815
+ // returnAjaxResult не передаём: batchByChunk.make жёстко ставит false сам,
816
+ // а его тип опций этот ключ не принимает
817
+ return v2().batchByChunk.make({ calls, options: { isHaltOnError } });
818
+ };
819
+
820
+ return { callMethod, callListMethod, fetchListMethod, callBatch, callBatchByChunk } satisfies FacadeMethods;
466
821
  }
467
822
 
468
823
  /**
469
824
  * Proxy-обёртка вокруг B24OAuth.
470
825
  *
471
- * Методы из RETRYABLE_METHODS оборачиваются retry-логикой, все остальные
472
- * привязываются к оригинальному объекту через bind: класс B24OAuth использует
473
- * приватные поля (#authOAuthManager), и при вызове метода с this === Proxy
474
- * движок бросает "Cannot read private member".
826
+ * Имена из FACADE_METHODS резолвятся в собственные реализации пакета поверх
827
+ * `actions.v2.*`; из них методы RETRYABLE_METHODS дополнительно оборачиваются
828
+ * retry-логикой. Все остальные методы привязываются к оригинальному объекту
829
+ * через bind: класс B24OAuth использует приватные поля (#authOAuthManager),
830
+ * и при вызове метода с this === Proxy движок бросает "Cannot read private member".
475
831
  *
476
832
  * Обёртки кешируются — по одной на метод, чтобы не ломать сравнение по ссылке.
477
833
  */
478
- function wrapB24WithRetry(b24: B24OAuth): B24OAuth {
834
+ function wrapB24WithRetry(b24: B24OAuth): B24Client {
479
835
  const methodCache = new Map<string, Function>();
836
+ // Фасад создаётся один раз на экземпляр: он замкнут на конкретный target,
837
+ // а пересоздание на каждом обращении ломало бы кеш и сравнение по ссылке
838
+ const facade = createFacade(b24);
480
839
 
481
840
  return new Proxy(b24, {
482
841
  get(target, prop) {
842
+ // Имена фасада проверяем до Reflect.get: пока SDK ещё объявляет свои
843
+ // deprecated-методы, иначе мы отдавали бы их, а не свои
844
+ if (typeof prop === "string" && FACADE_METHODS.has(prop)) {
845
+ if (!methodCache.has(prop)) {
846
+ const impl = facade[prop]!;
847
+ methodCache.set(prop, RETRYABLE_METHODS.has(prop) ? withRetry(impl, target, prop) : impl);
848
+ }
849
+
850
+ return methodCache.get(prop);
851
+ }
852
+
483
853
  // Передаём target третьим аргументом: геттеры (например, auth)
484
854
  // тоже должны исполняться с this === target
485
855
  const value = Reflect.get(target, prop, target);
@@ -497,13 +867,21 @@ function wrapB24WithRetry(b24: B24OAuth): B24OAuth {
497
867
 
498
868
  return methodCache.get(prop);
499
869
  },
500
- });
870
+
871
+ // Страховка на будущее: когда SDK уберёт методы из прототипа,
872
+ // "callMethod" in $b24 обязано остаться истинным (eventB24.ts проверяет
873
+ // наличие метода перед работой). Цель расширяема, лишние true законны
874
+ has(target, prop) {
875
+ if (typeof prop === "string" && FACADE_METHODS.has(prop)) return true;
876
+ return Reflect.has(target, prop);
877
+ },
878
+ }) as B24Client;
501
879
  }
502
880
 
503
881
  // ==================== Инициализация ====================
504
882
 
505
883
  let _b24Raw = createB24Instance();
506
- export let $b24 = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
884
+ export let $b24: B24Client | null = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
507
885
 
508
886
  /** Настраивает колбэк автосохранения токенов на экземпляре B24OAuth */
509
887
  function setupRefreshCallback(raw: B24OAuth): void {
@@ -1,5 +1,4 @@
1
- import type { B24OAuth } from "@bitrix24/b24jssdk";
2
- import { $b24, getResultData } from "./b24.ts";
1
+ import { $b24, getResultData, type B24MethodCaller } from "./b24.ts";
3
2
 
4
3
  // ==================== Типы ====================
5
4
 
@@ -83,14 +82,16 @@ function normalizeMessages(messages: unknown): any[] {
83
82
  * Класс для работы с офлайн-событиями Bitrix24
84
83
  */
85
84
  export class Event {
86
- private b24: B24OAuth;
85
+ // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
86
+ // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
87
+ private b24: B24MethodCaller;
87
88
 
88
89
  /** Расширения видео-файлов */
89
90
  private static readonly VIDEO_EXTENSIONS = ["mp4", "mov", "avi", "mkv", "webm", "3gp", "m4v"];
90
91
  /** Расширения изображений */
91
92
  private static readonly IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
92
93
 
93
- constructor(b24: B24OAuth) {
94
+ constructor(b24: B24MethodCaller) {
94
95
  if (!b24 || typeof b24.callMethod !== "function") {
95
96
  throw new Error("Event: передали некорректный b24 или не передали вообще");
96
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -1,5 +1,4 @@
1
- import type { B24OAuth } from "@bitrix24/b24jssdk";
2
- import { getResultData } from "../bitrix24/b24.ts";
1
+ import { getResultData, type B24MethodCaller } from "../bitrix24/b24.ts";
3
2
  import { fetchRetry } from "../utils/fetchRetry.ts";
4
3
 
5
4
  // ==================== Типы ====================
@@ -35,9 +34,11 @@ interface SmsResult {
35
34
  export class Smsgold {
36
35
  private user: string;
37
36
  private pass: string;
38
- private b24: B24OAuth | null;
37
+ // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
38
+ // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
39
+ private b24: B24MethodCaller | null;
39
40
 
40
- constructor(authParam: SmsgoldAuthParam, b24: B24OAuth | null = null) {
41
+ constructor(authParam: SmsgoldAuthParam, b24: B24MethodCaller | null = null) {
41
42
  this.user = authParam.user;
42
43
  this.pass = authParam.pass;
43
44
  this.b24 = b24;