@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.
@@ -0,0 +1,186 @@
1
+ import { B24OAuth, Logger, LogLevel } from "@bitrix24/b24jssdk";
2
+ import type { B24OAuthParams, B24OAuthSecret, AuthData, Handler, LogRecord, Formatter, RestrictionParams } from "@bitrix24/b24jssdk";
3
+ import { logs } from "../../logs/logs.ts";
4
+ import { APP_ENV, CLIENT_ID, CLIENT_SECRET, confAuthB24, cleanDomain } from "./config.ts";
5
+
6
+ // ==================== Константы ====================
7
+
8
+ /**
9
+ * Параметры ограничителя SDK. Передаются третьим аргументом конструктора B24OAuth
10
+ * и сливаются там с дефолтами `ParamsFactory.getDefault()`.
11
+ *
12
+ * Единый инвариант: **транспортные сбои SDK не повторяет вообще, повторяет только
13
+ * наш слой**, где работает гейт NON_IDEMPOTENT_METHOD_RE. У SDK нет понятия
14
+ * «создающий вызов»: оборванный crm.deal.add он повторил бы трижды и создал три сделки.
15
+ *
16
+ * Одного `retryOnNetworkError: false` для этого мало. Он добавляет в жёсткий список
17
+ * ровно два кода — NETWORK_ERROR и REQUEST_TIMEOUT, — а SDK конвертирует в них только
18
+ * `ERR_NETWORK` и `ECONNABORTED`. Остальные транспортные ошибки Node доезжают до
19
+ * лимитера под своим кодом (`ECONNRESET` при обрыве сокета, `ERR_BAD_RESPONSE` при
20
+ * 502/504 от шлюза), не находятся ни в жёстком, ни в мягком списке и потому считаются
21
+ * временными — то есть повторяются с backoff. Замерено на локальном сервере, рвущем
22
+ * соединение после приёма запроса: без hardErrorCodes портал выполняет crm.deal.add
23
+ * три раза, с ними — один. На SDK 2.0.0 был один: там транспорт маскировался в
24
+ * JSSDK_UNKNOWN_ERROR, а тот во встроенном жёстком списке.
25
+ *
26
+ * `ERR_NETWORK` и `ECONNABORTED` в списке не нужны: до лимитера они не доживают.
27
+ *
28
+ * Плата за список: `ECONNREFUSED`/`ENOTFOUND`/`EAI_AGAIN` доказывают, что запрос не ушёл,
29
+ * и SDK мог бы повторить их безопасно даже для создающих вызовов. Отказываемся сознательно —
30
+ * ровно так вёл себя 2.0.0, а один понятный инвариант дороже трёх сэкономленных попыток.
31
+ */
32
+ export const SDK_RESTRICTION_PARAMS = {
33
+ retryOnNetworkError: false,
34
+ hardErrorCodes: [
35
+ "ECONNRESET",
36
+ "ECONNREFUSED",
37
+ "ENOTFOUND",
38
+ "ETIMEDOUT",
39
+ "EHOSTUNREACH",
40
+ "ENETUNREACH",
41
+ "EAI_AGAIN",
42
+ "EPIPE",
43
+ "EPROTO",
44
+ "ERR_BAD_RESPONSE",
45
+ ],
46
+ } as const satisfies RestrictionParams;
47
+
48
+ // ==================== Логгер SDK ====================
49
+
50
+ /**
51
+ * Мост из логгера SDK в `logs` пакета.
52
+ *
53
+ * Порог WARNING обязателен: SDK пишет `post/send` и `post/response` на уровне `info`
54
+ * на каждый запрос и `http batch request starting/completed` на `debug` — без фильтра
55
+ * это залило бы лог.
56
+ *
57
+ * `AbstractHandler` объявлен в типах SDK, но в рантайме не экспортируется,
58
+ * поэтому реализуем интерфейс `Handler` обычным классом.
59
+ */
60
+ export class B24SdkLogHandler implements Handler {
61
+ private formatter: Formatter | null = null;
62
+
63
+ isHandling(level: LogLevel): boolean {
64
+ return level >= LogLevel.WARNING;
65
+ }
66
+
67
+ shouldBubble(): boolean {
68
+ return true;
69
+ }
70
+
71
+ setFormatter(formatter: Formatter): void {
72
+ this.formatter = formatter;
73
+ }
74
+
75
+ getFormatter(): Formatter | null {
76
+ return this.formatter;
77
+ }
78
+
79
+ async handle(record: LogRecord): Promise<boolean> {
80
+ // Бросать отсюда нельзя ни при каких обстоятельствах: Logger.log() делает
81
+ // await handle(), но лимитер зовёт логгер без await — исключение стало бы
82
+ // unhandled rejection и на дефолтных настройках Node убило бы процесс.
83
+ // Путь к броску реален: logs.add() читает conf, а тот перечитывает файл
84
+ // на каждом обращении и падает на битом log.json. В catch пишем через
85
+ // console.error, а не через logs — иначе рискуем зациклиться на той же ошибке.
86
+ try {
87
+ const status = Number(record.context?.status);
88
+
89
+ // Лимитер SDK через error() пишет любой 4xx кроме 408/429 как
90
+ // «non-retryable client error». Портал отдаёт 400 на штатные «мягкие»
91
+ // ошибки (несуществующая сущность), которые README описывает как
92
+ // нормальный путь через getResultData(): проверка существования в цикле
93
+ // дала бы строку на итерацию. Такие сообщения — уровень debug.
94
+ //
95
+ // 408 и 429 из правила исключены на будущее, сегодня эта ветка недостижима:
96
+ // status попадает в контекст записи уровня WARNING и выше ровно в одном месте
97
+ // SDK — #logNonRetryableClientError, а он вызывается из-под условия, которое
98
+ // 408 и 429 уже исключает. Сообщения лимитера про 429/503 идут другим путём,
99
+ // без status в контексте, и маппятся в warn независимо от этой строки.
100
+ // Условие оставлено потому, что фейлит в безопасную сторону: начни SDK класть
101
+ // status в такие записи — таймаут и упор в лимит будут видны, а не утонут в debug.
102
+ //
103
+ // Остальное: уровень ERROR у SDK — это диагностика отдельной неуспешной
104
+ // попытки. На транспортном сбое их пять — по числу наших попыток, повторы SDK
105
+ // выключены (см. SDK_RESTRICTION_PARAMS). На 429/503 и неизвестных 5xx SDK
106
+ // повторяет по-прежнему, там до пятнадцати: три внутри каждой из наших пяти.
107
+ // Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
108
+ // окончательный провал вызова логирует retry-слой, ровно один раз.
109
+ const isQuietClientError = status >= 400 && status < 500 && status !== 408 && status !== 429;
110
+ const level = isQuietClientError ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
111
+
112
+ // Контекст SDK (requestId, method, code, wait, status) уже прогнан
113
+ // через redactSensitiveParams — токены в него не попадают
114
+ const context = record.context && Object.keys(record.context).length > 0 ? record.context : undefined;
115
+
116
+ logs.add(`SDK B24: ${record.message}`, level, context);
117
+ } catch (error: unknown) {
118
+ const msg = error instanceof Error ? error.message : String(error);
119
+ console.error(`B24SdkLogHandler: не удалось записать сообщение SDK — ${msg}`);
120
+ }
121
+
122
+ return true;
123
+ }
124
+ }
125
+
126
+ // ==================== Создание экземпляра B24OAuth ====================
127
+
128
+ export function createB24Instance(): B24OAuth | null {
129
+ // Проект вообще не использует OAuth: ходит в B24 по входящему вебхуку либо берёт
130
+ // из пакета только logs/fetchRetry. Отсутствие авторизации для него — норма, а не
131
+ // ошибка, поэтому уровень debug: иначе ложная строка попадает в мониторинг.
132
+ // Признак «OAuth задуман» — наличие credentials приложения в .env.
133
+ if (!CLIENT_ID && !CLIENT_SECRET) {
134
+ logs.add("OAuth Bitrix24 не настроен (APP_B24_CLIENT_ID и APP_B24_CLIENT_SECRET не заданы), $b24 = null", "debug");
135
+ return null;
136
+ }
137
+
138
+ if (!CLIENT_ID || !CLIENT_SECRET) {
139
+ logs.add("Не заданы APP_B24_CLIENT_ID или APP_B24_CLIENT_SECRET в .env", "error");
140
+ return null;
141
+ }
142
+
143
+ const store = confAuthB24.store as Record<string, AuthData>;
144
+ const authConfig = store[APP_ENV];
145
+
146
+ if (!authConfig?.domain || !authConfig?.access_token || !authConfig?.refresh_token) {
147
+ logs.add("В конфиге authB24 не хватает данных для авторизации. Сохрани токены через клиент и перезагрузи докер", "error");
148
+ return null;
149
+ }
150
+
151
+ const domain = cleanDomain(authConfig.domain);
152
+
153
+ const authParams: B24OAuthParams = {
154
+ applicationToken: "",
155
+ userId: 0,
156
+ memberId: authConfig.member_id,
157
+ accessToken: authConfig.access_token,
158
+ refreshToken: authConfig.refresh_token,
159
+ expires: authConfig.expires,
160
+ expiresIn: authConfig.expires_in || 1800,
161
+ scope: "",
162
+ domain,
163
+ clientEndpoint: `https://${domain}/rest/`,
164
+ serverEndpoint: "https://oauth.bitrix.info/rest/",
165
+ status: "L",
166
+ issuer: "store",
167
+ };
168
+
169
+ const secret: B24OAuthSecret = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET };
170
+
171
+ // Параметры ограничителя задаём третьим аргументом конструктора: SDK сливает их
172
+ // с дефолтами (`{ ...ParamsFactory.getDefault(), ...restrictionParams }` в AbstractHttp)
173
+ // и передаёт обоим http-клиентам, v2 и v3, ещё до того как экземпляр можно использовать.
174
+ // Через setRestrictionManagerParams() было бы окно между созданием и настройкой,
175
+ // а результат вызова к тому же непроверяем: он завершается Promise.allSettled и
176
+ // не отклоняется никогда.
177
+ const b24 = new B24OAuth(authParams, secret, { restrictionParams: SDK_RESTRICTION_PARAMS });
178
+
179
+ // Диагностика SDK от WARNING и выше уходит в logs пакета: предупреждения
180
+ // callList/fetchList про игнорируемый order и остановку пагинации, сообщения лимитера.
181
+ // Дефолтный NullLogger не годится — LoggerFactory.forcedLog пишет мимо логгера
182
+ // прямо в console.warn, но после перехода на фасад этот путь у нас недостижим.
183
+ b24.setLogger(Logger.create("npm-app-back-b24").pushHandler(new B24SdkLogHandler()));
184
+
185
+ return b24;
186
+ }
@@ -0,0 +1,78 @@
1
+ import type { B24OAuth } from "@bitrix24/b24jssdk";
2
+ import { createFacade } from "./facade.ts";
3
+ import { withRetry } from "./retry.ts";
4
+ import type { B24Client } from "./types.ts";
5
+
6
+ // ==================== Константы ====================
7
+
8
+ /** Методы фасада, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
9
+ export const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
10
+
11
+ /**
12
+ * Имена, которые Proxy резолвит в собственную реализацию пакета поверх `actions.v2.*`.
13
+ *
14
+ * Шире, чем RETRYABLE_METHODS: `fetchListMethod` и `callBatchByChunk` мы реализуем,
15
+ * но не ретраим. `callBatchByChunk` в наборе обязателен — пока хоть один deprecated-метод
16
+ * SDK достижим через `$b24`, он будет писать предупреждение об устаревании.
17
+ */
18
+ export const FACADE_METHODS = new Set(["callMethod", "callListMethod", "fetchListMethod", "callBatch", "callBatchByChunk"]);
19
+
20
+ // ==================== Proxy-обёртка ====================
21
+
22
+ /**
23
+ * Proxy-обёртка вокруг B24OAuth.
24
+ *
25
+ * Имена из FACADE_METHODS резолвятся в собственные реализации пакета поверх
26
+ * `actions.v2.*`; из них методы RETRYABLE_METHODS дополнительно оборачиваются
27
+ * retry-логикой. Все остальные методы привязываются к оригинальному объекту
28
+ * через bind: класс B24OAuth использует приватные поля (#authOAuthManager),
29
+ * и при вызове метода с this === Proxy движок бросает "Cannot read private member".
30
+ *
31
+ * Обёртки кешируются — по одной на метод, чтобы не ломать сравнение по ссылке.
32
+ */
33
+ export function wrapB24WithRetry(b24: B24OAuth): B24Client {
34
+ const methodCache = new Map<string, Function>();
35
+ // Фасад создаётся один раз на экземпляр: он замкнут на конкретный target,
36
+ // а пересоздание на каждом обращении ломало бы кеш и сравнение по ссылке
37
+ const facade = createFacade(b24);
38
+
39
+ return new Proxy(b24, {
40
+ get(target, prop) {
41
+ // Имена фасада проверяем до Reflect.get: пока SDK ещё объявляет свои
42
+ // deprecated-методы, иначе мы отдавали бы их, а не свои
43
+ if (typeof prop === "string" && FACADE_METHODS.has(prop)) {
44
+ if (!methodCache.has(prop)) {
45
+ const impl = facade[prop]!;
46
+ methodCache.set(prop, RETRYABLE_METHODS.has(prop) ? withRetry(impl, target, prop) : impl);
47
+ }
48
+
49
+ return methodCache.get(prop);
50
+ }
51
+
52
+ // Передаём target третьим аргументом: геттеры (например, auth)
53
+ // тоже должны исполняться с this === target
54
+ const value = Reflect.get(target, prop, target);
55
+
56
+ if (typeof prop !== "string" || typeof value !== "function") {
57
+ return value;
58
+ }
59
+
60
+ if (!methodCache.has(prop)) {
61
+ const wrapped = RETRYABLE_METHODS.has(prop)
62
+ ? withRetry(value as (...args: any[]) => Promise<any>, target, prop)
63
+ : value.bind(target);
64
+ methodCache.set(prop, wrapped);
65
+ }
66
+
67
+ return methodCache.get(prop);
68
+ },
69
+
70
+ // Страховка на будущее: когда SDK уберёт методы из прототипа,
71
+ // "callMethod" in $b24 обязано остаться истинным (eventB24.ts проверяет
72
+ // наличие метода перед работой). Цель расширяема, лишние true законны
73
+ has(target, prop) {
74
+ if (typeof prop === "string" && FACADE_METHODS.has(prop)) return true;
75
+ return Reflect.has(target, prop);
76
+ },
77
+ }) as B24Client;
78
+ }