bx-rest 1.2.0 → 1.4.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.en.md +235 -2
  3. package/README.md +235 -2
  4. package/esm2022/functions/errors.mjs +18 -2
  5. package/esm2022/public-api.mjs +6 -2
  6. package/esm2022/rest/base.mjs +3 -3
  7. package/esm2022/services/frame/frame.mjs +347 -0
  8. package/esm2022/services/frame/transport.mjs +224 -0
  9. package/esm2022/services/frame/types.mjs +2 -0
  10. package/esm2022/services/http/HttpBX.mjs +39 -17
  11. package/esm2022/services/http/authRefresh.mjs +89 -0
  12. package/esm2022/services/http/sessionKey.mjs +8 -1
  13. package/esm2022/services/navvy/extends/navvy-pag-base.mjs +25 -4
  14. package/esm2022/services/navvy/navvy-alternative-pagination-navigation.mjs +2 -2
  15. package/esm2022/services/navvy/navvy-pag-nav-result-key-object.mjs +2 -2
  16. package/esm2022/services/navvy/navvy-pag-nav-result-key.mjs +2 -2
  17. package/esm2022/services/navvy/navvy-pag-nav-tasks.mjs +2 -2
  18. package/esm2022/services/navvy/navvy-pag-nav-with-useless-key.mjs +2 -2
  19. package/esm2022/services/navvy/navvy-pag.mjs +2 -2
  20. package/esm2022/typification/settings.mjs +1 -1
  21. package/fesm2022/bx-rest.mjs +820 -101
  22. package/fesm2022/bx-rest.mjs.map +1 -1
  23. package/functions/errors.d.ts +17 -0
  24. package/package.json +1 -1
  25. package/public-api.d.ts +5 -1
  26. package/rest/base.d.ts +1 -1
  27. package/services/frame/frame.d.ts +191 -0
  28. package/services/frame/transport.d.ts +75 -0
  29. package/services/frame/types.d.ts +123 -0
  30. package/services/http/HttpBX.d.ts +15 -4
  31. package/services/http/authRefresh.d.ts +19 -0
  32. package/services/http/sessionKey.d.ts +5 -0
  33. package/services/navvy/extends/navvy-pag-base.d.ts +11 -0
  34. package/typification/settings.d.ts +24 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,59 @@
7
7
  История до версии 1.0.0 здесь не восстанавливалась — смотрите `git log` для
8
8
  более ранних изменений.
9
9
 
10
+ ## [1.4.0] - 2026-10-29
11
+
12
+ ### Added
13
+
14
+ - Автоматическое продление авторизации — `BXRestSettings.auth.refresh`.
15
+ Если запрос упал с ошибкой авторизации, библиотека вызывает эту функцию и
16
+ повторяет запрос один раз, заново читая токен, адрес и заголовки из
17
+ настроек. Работает во всех запросах: `.res()`, `.resAll()` (включая
18
+ batch), `.resVanilla()`, `.mapForVanilla()`, `BXRestRequest.post()`/`.get()`,
19
+ `BXRest.batch()`. Одновременно упавшие запросы ждут одно общее продление.
20
+ Если продление упало, наружу уходит исходная ошибка запроса, а причина —
21
+ в её поле `authRefreshError` (`getAuthRefreshError(err)`). Без
22
+ `auth.refresh` запросы не повторяются, как и раньше.
23
+ - `isBXRestAuthError()` — условие продления по умолчанию: ошибка Bitrix
24
+ `expired_token` или `invalid_token`, либо ответ 401 без тела ошибки Bitrix
25
+ (например, пустой 401 от бэкенд-прокси). Другие ошибки Bitrix со статусом
26
+ 401 (`insufficient_scope`, `ACCESS_DENIED`, ...) продлением не лечатся и не
27
+ повторяются. Условие переопределяется через `auth.isAuthError`.
28
+ - `BXRestFrame` — команды порталу из iframe приложения без подключения
29
+ BX24.js: те же команды и имена, что у BX24.js, но с промисами вместо
30
+ колбэков. Данные запуска (`init()`), токен приложения (`getAuth()`,
31
+ `refreshAuth()`), установка (`installFinish()`, `firstRunFinish()`), размер
32
+ и заголовок фрейма, прокрутка и перезагрузка страницы, диалоги выбора
33
+ сотрудников, прав доступа и объектов CRM (с отменой через `signal`),
34
+ слайдеры (`openApplication()`, `openPath()`, `closeApplication()`),
35
+ мессенджер (`im.*`), встраивания (`placement.info/getInterface/call/bindEvent`)
36
+ и настройки (`userOption`, `appOption`). Ошибки — `BXRestFrameError` с
37
+ полем `code`. Использует внутренний недокументированный протокол
38
+ postMessage, который Bitrix24 может изменить; сверен с BX24.js от
39
+ 2022-03-17 и с ответной стороной портала — подробности в README, раздел
40
+ «Работа в iframe без BX24.js».
41
+ - У `BXRestApiError` появилось поле `authRefreshError`.
42
+
43
+ ### Changed
44
+
45
+ - Обычный `Error`, которым `.res()` завершается на ответ не от Bitrix
46
+ (например, HTML-страницу или пустой 401 от прокси), теперь хранит исходную
47
+ ошибку axios в `cause` — с HTTP-статусом в `cause.response.status`. Тип и
48
+ текст ошибки прежние.
49
+
50
+ ## [1.3.0] - 2026-10-28
51
+
52
+ ### Added
53
+
54
+ - `BXRestSettings.headers` — статичные HTTP-заголовки, которые прикладываются
55
+ к каждому запросу (например, `{ 'X-Bitrix-App': 'smart-process-pro' }`).
56
+ - `.withHeaders()` у Navvy-запросов — заголовки только для одного запроса,
57
+ поверх `BXRestSettings.headers`. Действуют и на batch-запросы `.resAll()`.
58
+ `BXRestRequest.post()`/`.get()` и `BXRest.batch()` принимают те же заголовки
59
+ последним необязательным аргументом.
60
+ - `Content-Type` библиотека по-прежнему выставляет сама — заголовками он не
61
+ перебивается.
62
+
10
63
  ## [1.2.0] - 2026-10-28
11
64
 
12
65
  ### Fixed
package/README.en.md CHANGED
@@ -18,7 +18,13 @@ Also known as: Bitrix24 REST client, BX24 REST SDK, Bitrix24 API client, Bitrix
18
18
  - [Result modes](#result-modes)
19
19
  - [Authentication and OAuth2](#authentication-and-oauth2)
20
20
  - [`withCredentials`](#withcredentials)
21
+ - [HTTP headers](#http-headers)
21
22
  - [Error handling](#error-handling)
23
+ - [Authorization refresh](#authorization-refresh)
24
+ - [A token from the portal without BX24.js](#a-token-from-the-portal-without-bx24js)
25
+ - [Recipe: straight to the portal](#recipe-straight-to-the-portal)
26
+ - [Recipe: through a backend proxy](#recipe-through-a-backend-proxy)
27
+ - [Working in an iframe without BX24.js](#working-in-an-iframe-without-bx24js)
22
28
  - [Usage in Angular](#usage-in-angular)
23
29
  - [SessionKeyServices](#sessionkeyservices)
24
30
  - [Usage in Vue](#usage-in-vue)
@@ -154,6 +160,34 @@ BXRestSettings.update({
154
160
  })
155
161
  ```
156
162
 
163
+ ## HTTP headers
164
+
165
+ Put static headers that every request needs into `BXRestSettings.headers`:
166
+
167
+ ```typescript
168
+ BXRestSettings.update({
169
+ headers: {
170
+ 'X-Bitrix-App': 'smart-process-pro'
171
+ }
172
+ })
173
+ ```
174
+
175
+ They are attached to every request the library makes, including the `.resAll()` batch requests, and, like the other settings, are read at subscribe time. `update()` merges settings only at the top level, so `headers` is replaced as a whole — to add a header to the ones already set, pass the full set.
176
+
177
+ For a single request, use `.withHeaders()`. Its headers are applied on top of `BXRestSettings.headers`; when a name matches (case-insensitively), the `.withHeaders()` value wins. It applies to `.res()`, `.resAll()`, `.resVanilla()` and `.mapForVanilla()`:
178
+
179
+ ```typescript
180
+ const deals = await firstValueFrom(
181
+ bxRest.crm.deal.list({ select: ['ID', 'TITLE'], filter: {}, start: 0 })
182
+ .withHeaders({ 'X-Request-Source': 'report' })
183
+ .resAll()
184
+ )
185
+ ```
186
+
187
+ The library always sets `Content-Type` itself (`application/json`, or `application/x-www-form-urlencoded` for `auth.key: 'sessid'`): the server parses the request body based on it, so headers can't override it.
188
+
189
+ For cross-origin requests, the server — the portal or your proxy — must allow a non-standard header in `Access-Control-Allow-Headers` of its CORS preflight (`OPTIONS`) response, or the browser will block the request. The header doesn't add an extra request: the library's JSON requests already go through a preflight because of `Content-Type: application/json`.
190
+
157
191
  ## Error handling
158
192
 
159
193
  `.res()` can fail in three ways — which one depends on how the portal answered.
@@ -162,7 +196,7 @@ BXRestSettings.update({
162
196
  | --- | --- |
163
197
  | Bitrix API error with a non-2xx HTTP status — this is how Bitrix sends most errors: `expired_token` (401), `ERROR_METHOD_NOT_FOUND` (404), `QUERY_LIMIT_EXCEEDED` (503), `Not found` from `crm.*.get` (400) | The returned `Observable` errors with a `BXRestApiError`, with or without `throwOnApiError`. |
164
198
  | Bitrix API error in the body of a 2xx response | By default, `.res()` resolves normally with `undefined` — **it does not throw**. Pass `{ throwOnApiError: true }` to make it throw a `BXRestApiError` instead. |
165
- | Transport / configuration errors — network failure, missing auth, missing base URL, a response that is not from Bitrix (e.g. a proxy's HTML error page) | The returned `Observable` errors with a plain `Error`. |
199
+ | Transport / configuration errors — network failure, missing auth, missing base URL, a response that is not from Bitrix (e.g. a proxy's HTML error page) | The returned `Observable` errors with a plain `Error`. The original error is in its `cause`: for a response that is not from Bitrix it is the axios error, with the HTTP status in `cause.response.status`. |
166
200
 
167
201
  `BXRestApiError` fields:
168
202
 
@@ -193,7 +227,7 @@ bxRest.crm.deal.list({
193
227
  if (err instanceof BXRestApiError) {
194
228
  console.error('Bitrix API error:', err.status, err.error, err.error_description)
195
229
  if (err.error === 'expired_token') {
196
- // refresh the token and retry the request
230
+ // auth.refresh is not set or did not help — see "Authorization refresh"
197
231
  }
198
232
  } else {
199
233
  console.error('Request failed:', err.message)
@@ -227,6 +261,205 @@ if (!answer || !isBXRestAnswerSuccess(answer)) {
227
261
  }
228
262
  ```
229
263
 
264
+ ## Authorization refresh
265
+
266
+ With `auth.refresh` set, the library refreshes an expired authorization by itself. When a request fails with an authorization error, the library calls `auth.refresh()` and retries the request, and your code just gets the result.
267
+
268
+ ```typescript
269
+ BXRestSettings.update({
270
+ auth: {
271
+ source: () => accessToken,
272
+ key: 'auth',
273
+ // get a new authorization and put it where source reads it from
274
+ refresh: async () => {
275
+ accessToken = await getNewToken()
276
+ }
277
+ },
278
+ // ...
279
+ })
280
+ ```
281
+
282
+ | `auth` field | Value |
283
+ | --- | --- |
284
+ | `refresh` | `() => Promise<unknown>`. Gets a new authorization. The promise value is not used: the retry reads the token, the URL and the headers from the settings again — so the function has to put the new token where `auth.source` reads it from (or, through a proxy, hand it to the server). |
285
+ | `isAuthError` | `(error: unknown) => boolean`, optional. Which request error counts as an authorization error. It gets the original HTTP request error — usually an `AxiosError`. Defaults to the exported `isBXRestAuthError`, which is easy to extend: `error => isBXRestAuthError(error) \|\| myCheck(error)`. |
286
+
287
+ Without `auth.refresh` the library behaves as before: requests are not retried. `update()` merges settings at the top level only, so `BXRestSettings.update({ auth: {...} })` replaces the whole `auth` — `refresh` included. When you change `auth`, pass `refresh` again.
288
+
289
+ How the retry works:
290
+
291
+ - **What counts as an authorization error** (`isBXRestAuthError`): a response with the Bitrix error `expired_token` or `invalid_token`, whatever the HTTP status; a 401 response without a Bitrix error body — e.g. an empty 401 from a backend proxy that has no valid token. The other Bitrix system errors come with status 401 too (`insufficient_scope`, `NO_AUTH_FOUND`, `ACCESS_DENIED`, `INVALID_CREDENTIALS`, `user_access_error`, `OVERLOAD_LIMIT`), but a new token does not fix them — they are not retried.
292
+ - **One retry.** If the retry fails as well, its error goes out; there is no second refresh.
293
+ - **One refresh for everybody.** When several requests fail at once, `refresh()` is called once and all of them are retried. A request that was in flight while the token was being refreshed is retried without another refresh.
294
+ - **The refresh failed** (`refresh()` rejected or threw) — the original request error goes out, and the reason the refresh failed is in its `authRefreshError` field. `getAuthRefreshError(err)` reads it from any error:
295
+
296
+ ```typescript
297
+ import { BXRestApiError, getAuthRefreshError } from 'bx-rest'
298
+
299
+ bxRest.crm.deal.get({ ID: 1 }).res().subscribe({
300
+ error: err => {
301
+ if (err instanceof BXRestApiError && err.error === 'expired_token') {
302
+ console.error('Could not refresh the authorization:', getAuthRefreshError(err))
303
+ }
304
+ }
305
+ })
306
+ ```
307
+
308
+ - **Where it works.** In every library request: `.res()`, `.resAll()` (the first page and every batch request), `.resVanilla()`, `.mapForVanilla()`, `BXRestRequest.post()`/`.get()`, `BXRest.batch()`. The retry lives in the HTTP layer, so `.res()` already gets the result of the retry.
309
+ - **What is not retried:** errors in the body of a 2xx response (Bitrix sends `expired_token` with status 401) and errors of single commands inside a successful batch response (`result_error`) — the whole HTTP request is what gets retried.
310
+
311
+ ### A token from the portal without BX24.js
312
+
313
+ A browser cannot refresh a token through OAuth — that takes the `client_secret`. But the application is open in a portal iframe, the user is logged in to the portal, and the portal itself hands the application a token over postMessage — this is how `BX24.getAuth()` and `BX24.refreshAuth()` work. `BXRestFrame` does the same without loading BX24.js (the other BX24.js commands are in [Working in an iframe without BX24.js](#working-in-an-iframe-without-bx24js)):
314
+
315
+ ```typescript
316
+ import { BXRestFrame } from 'bx-rest'
317
+
318
+ const frame = new BXRestFrame() // optional: { timeout: 10000 }
319
+
320
+ const auth = await frame.getAuth() // the current authorization — like BX24.getAuth()
321
+ const fresh = await frame.refreshAuth() // a new authorization — like BX24.refreshAuth()
322
+ // { access_token, refresh_token, expires_in, domain, member_id }
323
+ ```
324
+
325
+ | Result field | Value |
326
+ | --- | --- |
327
+ | `access_token`, `refresh_token` | The application tokens. |
328
+ | `expires_in` | When the `access_token` expires — **milliseconds** since January 1, 1970, as in `BX24.getAuth()`. Not the lifetime in seconds, as in the OAuth server response. |
329
+ | `domain` | The portal address without the protocol, e.g. `mycompany.bitrix24.com`. |
330
+ | `member_id` | The permanent portal identifier. |
331
+
332
+ - **Tokens.** `BXRestFrame` does not log tokens and does not store them anywhere: every `getAuth()` and `refreshAuth()` call asks the portal again, and your code gets the result.
333
+ - **Errors** — `BXRestFrameError` with a `code` field: `NOT_IN_FRAME` (the page is not opened in an application iframe — fails right away), `TIMEOUT` (the portal did not answer within `timeout`, 10 seconds by default), `NO_AUTH` (the portal answered without a token), `BAD_RESPONSE` (the answer could not be parsed). When the portal cannot issue a new token (e.g. the application trial or paid period is over), it shows the "Unable to get new token!" `alert` and does not answer at all — such a refusal comes as `TIMEOUT`.
334
+ - **`getAuth()` and the expiry.** The portal gives the token lifetime at the moment of issue, not what is left of it, so on a page that has been open for a while `expires_in` from `getAuth()` points too far ahead (the same with BX24.js). Use `refreshAuth()` for a fresh token.
335
+ - **The portal domain.** Like BX24.js, `BXRestFrame` cannot vouch for it — it is set by whoever embedded the page in an iframe. So a server must check a token that came from the browser with the portal before trusting it.
336
+
337
+ ### Recipe: straight to the portal
338
+
339
+ The frontend keeps the token, requests go to `https://<portal>/rest/`. The refresh function gets a new token from the portal and puts it where `auth.source` reads it from — an in-memory variable here:
340
+
341
+ ```typescript
342
+ import { BXRestSettings, BXRestFrame } from 'bx-rest'
343
+
344
+ const frame = new BXRestFrame()
345
+ const auth = await frame.getAuth()
346
+ let accessToken = auth.access_token
347
+
348
+ BXRestSettings.update({
349
+ auth: {
350
+ source: () => accessToken,
351
+ key: 'auth',
352
+ refresh: async () => {
353
+ accessToken = (await frame.refreshAuth()).access_token
354
+ }
355
+ },
356
+ urls: {
357
+ source: 'string',
358
+ key: `https://${auth.domain}`,
359
+ additional_part: 'rest'
360
+ }
361
+ })
362
+ ```
363
+
364
+ With `auth.source: 'localStorage'` the refresh function writes the token to `localStorage[auth.key]` in the same way.
365
+
366
+ ### Recipe: through a backend proxy
367
+
368
+ The server keeps the token, and the browser authorizes with the proxy through a cookie session (`auth.source: 'off'`). A proxy without a valid token answers 401 with an empty body — an authorization error by default. The refresh function gets a new token from the portal and sends it to the server:
369
+
370
+ ```typescript
371
+ import { BXRestSettings, BXRestFrame } from 'bx-rest'
372
+
373
+ const APP = 'smart-process-pro'
374
+ const frame = new BXRestFrame()
375
+
376
+ BXRestSettings.update({
377
+ auth: {
378
+ source: 'off',
379
+ key: 'auth',
380
+ refresh: async () => {
381
+ const { access_token, refresh_token, expires_in } = await frame.refreshAuth()
382
+ const response = await fetch(`/api/bitrix/token/${APP}`, {
383
+ method: 'POST',
384
+ credentials: 'same-origin',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ access_token, refresh_token, expires_in })
387
+ })
388
+ if (!response.ok) {
389
+ // no request body in the message — it holds the tokens
390
+ throw new Error(`Token upload failed: HTTP ${response.status}`)
391
+ }
392
+ }
393
+ },
394
+ urls: {
395
+ source: 'string',
396
+ key: '/api/bitrix/rest/',
397
+ additional_part: '' // otherwise one more `rest/` is appended to the URL
398
+ },
399
+ headers: {
400
+ 'X-Bitrix-App': APP
401
+ }
402
+ })
403
+ ```
404
+
405
+ Errors of the refresh function end up in `authRefreshError` of the original error, and `.res()` may print the original error to the console — so keep tokens out of the error messages of the refresh function.
406
+
407
+ ## Working in an iframe without BX24.js
408
+
409
+ `BXRestFrame` is the commands an application in an iframe sends to the portal: the frame size, the selection dialogs, sliders, the messenger, placements, options, installation. The same commands and the same names as in BX24.js, but with promises instead of callbacks, and with no need to load BX24.js. bx-rest itself makes the REST requests, and `getAuth()`/`refreshAuth()` give the token for them (see [Authorization refresh](#authorization-refresh)).
410
+
411
+ ```typescript
412
+ import { BXRestFrame } from 'bx-rest'
413
+
414
+ const frame = new BXRestFrame()
415
+
416
+ const { lang, isAdmin, placement } = await frame.init()
417
+ await frame.fitWindow()
418
+ const user = await frame.selectUser()
419
+ const unsubscribe = frame.placement.bindEvent('Tab:onChange', data => console.log(data))
420
+ ```
421
+
422
+ Create one instance per page, when the application starts: the portal parameters are read in the constructor, and the portal reports the first run only in its first answer per page load. For the same reason, do not use `BXRestFrame` and BX24.js on one page.
423
+
424
+ | BX24.js | `BXRestFrame` | Answer |
425
+ | --- | --- | --- |
426
+ | `BX24.init()`, `isAdmin()`, `getLang()`, `getDomain()` | `init()` — `{ domain, lang, path, https, memberId, isAdmin, install, firstRun, placement, placementOptions, userOptions, appOptions }` | Asked once, without tokens |
427
+ | `BX24.getAuth()`, `BX24.refreshAuth()` | `getAuth()`, `refreshAuth()` | Asked from the portal every time |
428
+ | `BX24.installFinish()` | `installFinish()` | Only on the installer page (`install`); the portal reloads the page |
429
+ | the end of the `BX24.install()` chain | `firstRunFinish()` | Marks the first run as done; without it `firstRun` stays `true` |
430
+ | `BX24.resizeWindow(w, h)`, `BX24.fitWindow()` | `resizeWindow(w, h)`, `fitWindow()` | `{ width, height }` or `undefined` |
431
+ | `BX24.setTitle(title)` | `setTitle(title)` | `{ title }` or `undefined` |
432
+ | `BX24.scrollParentWindow(scroll)` | `scrollParentWindow(scroll)` | `{ scroll }` or `undefined` |
433
+ | `BX24.reloadWindow()` | `reloadWindow()` | No answer |
434
+ | `BX24.selectUser()`, `BX24.selectUsers()` | `selectUser({ title?, signal? })`, `selectUsers(...)` | An employee / an array of employees |
435
+ | `BX24.selectAccess(value)` | `selectAccess({ value?, signal? })` — `value`: codes that cannot be selected | An array of `{ provider, id, name }` |
436
+ | `BX24.selectCRM(params)` | `selectCRM({ entityType?, multiple?, value?, signal? })` | CRM items by type |
437
+ | `BX24.openApplication(params, cb, settings)` | `openApplication(params?, settings?, { signal? })` | When the slider is closed |
438
+ | `BX24.openPath(path)` | `openPath(path, { signal? })` | `{ result: 'close' }` or `{ result: 'error', errorCode }` |
439
+ | `BX24.closeApplication()` | `closeApplication()` | No answer |
440
+ | `BX24.im.callTo/phoneTo/openMessenger/openHistory` | `im.callTo/phoneTo/openMessenger/openHistory` | No answer |
441
+ | `BX24.placement.info()` | `placement.info()` | `{ placement, options }` from `init()` |
442
+ | `BX24.placement.getInterface()` | `placement.getInterface()` | `{ command, event }` |
443
+ | `BX24.placement.call(cmd, params)` | `placement.call(cmd, params?, { timeout? })` | The command answer or `undefined` |
444
+ | `BX24.placement.bindEvent(event, cb)` | `placement.bindEvent(event, handler)` | `handler` on every event; returns an unsubscribe function |
445
+ | `BX24.userOption.get/set` | `userOption.get/set` | A user option |
446
+ | `BX24.appOption.get/set` | `appOption.get/set` | `set` — administrators only, otherwise `ACCESS_DENIED` |
447
+
448
+ How the portal answers a command decides when the promise settles:
449
+
450
+ - **With an answer** (`init()`, `getAuth()`, `refreshAuth()`, `placement.getInterface()`, `appOption.set()`) — no answer within `timeout` (10 seconds by default) is a `TIMEOUT` error.
451
+ - **With an acknowledgement** (`resizeWindow()`, `fitWindow()`, `setTitle()`, `scrollParentWindow()`, `userOption.set()`, `firstRunFinish()`, `placement.call()`) — in some placements the portal does not answer them, so without an answer within `timeout` the promise resolves with `undefined`, not with an error.
452
+ - **Without an answer** (`reloadWindow()`, `closeApplication()`, `installFinish()`, `im.*`) — the promise resolves right after sending.
453
+ - **Waiting for the user** (`select*()`, `openApplication()`, `openPath()`) — with no time limit. When the user closes a dialog without selecting anything, the portal does not answer at all. Cancel the wait with `signal` (`AbortController`); calling the same dialog again cancels the previous wait, because the portal keeps one handler per dialog. A cancelled wait is a `CANCELLED` error.
454
+
455
+ `placement.bindEvent()` returns an unsubscribe function, but it only stops calling `handler`: the protocol has no way to cancel the subscription on the portal side.
456
+
457
+ - **Where it looks for the portal.** Like BX24.js, in `window.name`: the portal names the application iframe `DOMAIN|PROTOCOL|APP_SID`. If it is empty — in the page query (`?DOMAIN=…&PROTOCOL=…&APP_SID=…`) the portal opens the application handler with.
458
+ - **Security.** Commands go to the portal origin only (not `'*'`), answers are accepted only from `window.parent` and only from the portal origin.
459
+ - **Errors** — `BXRestFrameError` with a `code` field: `NOT_IN_FRAME` (the page is not opened in an application iframe — fails right away), `TIMEOUT`, `NO_AUTH`, `BAD_RESPONSE`, `CANCELLED`, `ACCESS_DENIED`. Wrong arguments of `resizeWindow()` and `scrollParentWindow()` are a `TypeError`.
460
+
461
+ > **The protocol is internal and undocumented.** The messages between an application and a Bitrix24 portal are not described in the documentation and may change without notice. `BXRestFrame` follows BX24.js — `https://api.bitrix24.com/api/v1/`, downloaded 2026-10-29, `Last-Modified: 2022-03-17`, SHA-256 `945ad491df8ffb7490b89f4914a439b5a3f575c950c3480562815716a3e07f23` — and is checked against the portal side, `/bitrix/js/rest/applayout.js` (`Last-Modified: 2024-05-15`, SHA-256 `3efd6ab57a4778493a26d10ae0987a82e19fc012ed9935f5c6afa78c4052fcb5`), and against the BX24.js documentation. It is tested against a fake portal, not against a live one.
462
+
230
463
  ## Usage in Angular
231
464
 
232
465
  Register the public clients and `Navvy` when you want to inject them into Angular services or components:
package/README.md CHANGED
@@ -18,7 +18,13 @@
18
18
  - [Режимы результата](#режимы-результата)
19
19
  - [Аутентификация и OAuth2](#аутентификация-и-oauth2)
20
20
  - [`withCredentials`](#withcredentials)
21
+ - [HTTP-заголовки](#http-заголовки)
21
22
  - [Обработка ошибок](#обработка-ошибок)
23
+ - [Продление авторизации](#продление-авторизации)
24
+ - [Токен от портала без BX24.js](#токен-от-портала-без-bx24js)
25
+ - [Рецепт: напрямую в портал](#рецепт-напрямую-в-портал)
26
+ - [Рецепт: через бэкенд-прокси](#рецепт-через-бэкенд-прокси)
27
+ - [Работа в iframe без BX24.js](#работа-в-iframe-без-bx24js)
22
28
  - [Использование в Angular](#использование-в-angular)
23
29
  - [SessionKeyServices](#sessionkeyservices)
24
30
  - [Использование в Vue](#использование-в-vue)
@@ -164,6 +170,34 @@ BXRestSettings.update({
164
170
  })
165
171
  ```
166
172
 
173
+ ## HTTP-заголовки
174
+
175
+ Статичные заголовки, которые нужны в каждом запросе, задайте в `BXRestSettings.headers`:
176
+
177
+ ```typescript
178
+ BXRestSettings.update({
179
+ headers: {
180
+ 'X-Bitrix-App': 'smart-process-pro'
181
+ }
182
+ })
183
+ ```
184
+
185
+ Они прикладываются ко всем запросам библиотеки, включая batch-запросы `.resAll()`, и, как и остальные настройки, читаются в момент подписки. `update()` сливает настройки только на верхнем уровне, поэтому `headers` заменяется целиком — чтобы добавить заголовок к уже заданным, передайте весь набор.
186
+
187
+ Для одного запроса используйте `.withHeaders()`. Его заголовки добавляются поверх `BXRestSettings.headers`, при совпадении имени (без учёта регистра) побеждает значение из `.withHeaders()`. Действует на `.res()`, `.resAll()`, `.resVanilla()` и `.mapForVanilla()`:
188
+
189
+ ```typescript
190
+ const deals = await firstValueFrom(
191
+ bxRest.crm.deal.list({ select: ['ID', 'TITLE'], filter: {}, start: 0 })
192
+ .withHeaders({ 'X-Request-Source': 'report' })
193
+ .resAll()
194
+ )
195
+ ```
196
+
197
+ `Content-Type` библиотека всегда выставляет сама (`application/json`, а для `auth.key: 'sessid'` — `application/x-www-form-urlencoded`): от него зависит, как сервер разберёт тело запроса, поэтому заголовками он не перебивается.
198
+
199
+ Для запросов на другой домен сервер — портал или ваш прокси — должен разрешить нестандартный заголовок в `Access-Control-Allow-Headers` ответа на CORS preflight (`OPTIONS`), иначе браузер заблокирует запрос. Лишнего запроса заголовок не добавляет: JSON-запросы библиотеки и так проходят через preflight из-за `Content-Type: application/json`.
200
+
167
201
  ## Обработка ошибок
168
202
 
169
203
  `.res()` может завершиться неудачей тремя способами — какой именно, зависит от того, как ответил портал.
@@ -172,7 +206,7 @@ BXRestSettings.update({
172
206
  | --- | --- |
173
207
  | Ошибка API Bitrix с HTTP-статусом не 2xx — так Bitrix отдаёт большинство ошибок: `expired_token` (401), `ERROR_METHOD_NOT_FOUND` (404), `QUERY_LIMIT_EXCEEDED` (503), `Not found` у `crm.*.get` (400) | Возвращённый `Observable` завершается ошибкой `BXRestApiError` — с `throwOnApiError` и без него. |
174
208
  | Ошибка API Bitrix в теле ответа со статусом 2xx | По умолчанию `.res()` резолвится обычным образом со значением `undefined` — **исключение не выбрасывается**. Передайте `{ throwOnApiError: true }`, чтобы вместо этого выбрасывалось `BXRestApiError`. |
175
- | Ошибки транспорта / конфигурации — сбой сети, отсутствие авторизации, отсутствие базового URL, ответ не от Bitrix (например, HTML-страница ошибки прокси) | Возвращённый `Observable` завершается обычной ошибкой `Error`. |
209
+ | Ошибки транспорта / конфигурации — сбой сети, отсутствие авторизации, отсутствие базового URL, ответ не от Bitrix (например, HTML-страница ошибки прокси) | Возвращённый `Observable` завершается обычной ошибкой `Error`. Исходная ошибка — в её `cause`: для ответа не от Bitrix это ошибка axios, HTTP-статус — в `cause.response.status`. |
176
210
 
177
211
  Поля `BXRestApiError`:
178
212
 
@@ -203,7 +237,7 @@ bxRest.crm.deal.list({
203
237
  if (err instanceof BXRestApiError) {
204
238
  console.error('Bitrix API error:', err.status, err.error, err.error_description)
205
239
  if (err.error === 'expired_token') {
206
- // обновить токен и повторить запрос
240
+ // auth.refresh не задан или не помог — см. «Продление авторизации»
207
241
  }
208
242
  } else {
209
243
  console.error('Request failed:', err.message)
@@ -237,6 +271,205 @@ if (!answer || !isBXRestAnswerSuccess(answer)) {
237
271
  }
238
272
  ```
239
273
 
274
+ ## Продление авторизации
275
+
276
+ Если задать `auth.refresh`, библиотека сама продлевает истёкшую авторизацию. Запрос упал с ошибкой авторизации — библиотека вызывает `auth.refresh()` и повторяет запрос, а прикладной код получает обычный результат.
277
+
278
+ ```typescript
279
+ BXRestSettings.update({
280
+ auth: {
281
+ source: () => accessToken,
282
+ key: 'auth',
283
+ // получить новую авторизацию и положить её туда, откуда её читает source
284
+ refresh: async () => {
285
+ accessToken = await getNewToken()
286
+ }
287
+ },
288
+ // ...
289
+ })
290
+ ```
291
+
292
+ | Поле `auth` | Значение |
293
+ | --- | --- |
294
+ | `refresh` | `() => Promise<unknown>`. Получает новую авторизацию. Значение промиса не используется: повтор заново читает токен, адрес и заголовки из настроек — поэтому функция должна положить новый токен туда, откуда его читает `auth.source` (или, при работе через прокси, передать его на сервер). |
295
+ | `isAuthError` | `(error: unknown) => boolean`, необязательно. Какую ошибку запроса считать ошибкой авторизации. Получает исходную ошибку HTTP-запроса — обычно `AxiosError`. По умолчанию — экспортируемая `isBXRestAuthError`, её удобно дополнять: `error => isBXRestAuthError(error) \|\| myCheck(error)`. |
296
+
297
+ Без `auth.refresh` библиотека ведёт себя как раньше: запросы не повторяются. `update()` сливает настройки только на верхнем уровне, поэтому `BXRestSettings.update({ auth: {...} })` заменяет `auth` целиком — вместе с `refresh`. Если меняете `auth`, передайте `refresh` снова.
298
+
299
+ Как работает повтор:
300
+
301
+ - **Что считается ошибкой авторизации** (`isBXRestAuthError`): ответ с ошибкой Bitrix `expired_token` или `invalid_token` — при любом HTTP-статусе; ответ 401 без тела ошибки Bitrix — например, пустой 401 от бэкенд-прокси, у которого нет действующего токена. Остальные системные ошибки Bitrix тоже приходят со статусом 401 (`insufficient_scope`, `NO_AUTH_FOUND`, `ACCESS_DENIED`, `INVALID_CREDENTIALS`, `user_access_error`, `OVERLOAD_LIMIT`), но новый токен их не исправит — они не повторяются.
302
+ - **Один повтор.** Если и повтор упал — наружу уходит его ошибка, второго продления не будет.
303
+ - **Одно продление на всех.** Если одновременно упало несколько запросов, `refresh()` вызывается один раз, а повторяются все. Запрос, который был в пути, пока токен продлевали, повторяется без нового продления.
304
+ - **Продление упало** (`refresh()` отклонил промис или бросил исключение) — наружу уходит исходная ошибка запроса, а причина сбоя продления — в её поле `authRefreshError`. Достать её из любой ошибки — `getAuthRefreshError(err)`:
305
+
306
+ ```typescript
307
+ import { BXRestApiError, getAuthRefreshError } from 'bx-rest'
308
+
309
+ bxRest.crm.deal.get({ ID: 1 }).res().subscribe({
310
+ error: err => {
311
+ if (err instanceof BXRestApiError && err.error === 'expired_token') {
312
+ console.error('Авторизацию продлить не удалось:', getAuthRefreshError(err))
313
+ }
314
+ }
315
+ })
316
+ ```
317
+
318
+ - **Где работает.** Во всех запросах библиотеки: `.res()`, `.resAll()` (и первая страница, и каждый batch-запрос), `.resVanilla()`, `.mapForVanilla()`, `BXRestRequest.post()`/`.get()`, `BXRest.batch()`. Повтор встроен в HTTP-слой, поэтому `.res()` получает уже результат повтора.
319
+ - **Что не повторяется:** ошибки в теле ответа со статусом 2xx (Bitrix отдаёт `expired_token` со статусом 401) и ошибки отдельных команд внутри успешного batch-ответа (`result_error`) — повторяется HTTP-запрос целиком.
320
+
321
+ ### Токен от портала без BX24.js
322
+
323
+ Обновить токен по OAuth прямо в браузере нельзя — для этого нужен `client_secret`. Но приложение открыто в iframe портала, пользователь в портале залогинен, и портал сам выдаёт приложению токен по postMessage — так работают `BX24.getAuth()` и `BX24.refreshAuth()`. `BXRestFrame` делает то же самое без подключения BX24.js (остальные команды BX24.js — в разделе [Работа в iframe без BX24.js](#работа-в-iframe-без-bx24js)):
324
+
325
+ ```typescript
326
+ import { BXRestFrame } from 'bx-rest'
327
+
328
+ const frame = new BXRestFrame() // необязательно: { timeout: 10000 }
329
+
330
+ const auth = await frame.getAuth() // текущая авторизация — как BX24.getAuth()
331
+ const fresh = await frame.refreshAuth() // новая авторизация — как BX24.refreshAuth()
332
+ // { access_token, refresh_token, expires_in, domain, member_id }
333
+ ```
334
+
335
+ | Поле результата | Значение |
336
+ | --- | --- |
337
+ | `access_token`, `refresh_token` | Токены приложения. |
338
+ | `expires_in` | Момент истечения `access_token` — **миллисекунды** с 1 января 1970 года, как у `BX24.getAuth()`. Не срок жизни в секундах, как в ответе OAuth-сервера. |
339
+ | `domain` | Адрес портала без протокола, например `mycompany.bitrix24.ru`. |
340
+ | `member_id` | Постоянный идентификатор портала. |
341
+
342
+ - **Токены** `BXRestFrame` не логирует и никуда не сохраняет: каждый вызов `getAuth()` и `refreshAuth()` спрашивает портал заново, результат получает ваш код.
343
+ - **Ошибки** — `BXRestFrameError` с полем `code`: `NOT_IN_FRAME` (страница открыта не в iframe приложения — ошибка сразу), `TIMEOUT` (портал не ответил за `timeout`, по умолчанию 10 секунд), `NO_AUTH` (портал ответил без токена), `BAD_RESPONSE` (ответ не удалось разобрать). Если портал не может выдать новый токен (например, у приложения закончился пробный или платный период), он показывает `alert` «Unable to get new token!» и не отвечает вовсе — такой отказ приходит как `TIMEOUT`.
344
+ - **`getAuth()` и срок действия.** Портал отдаёт срок жизни токена на момент выдачи, а не остаток, поэтому `expires_in` от `getAuth()` на давно открытой странице завышен (у BX24.js так же). Для свежего токена используйте `refreshAuth()`.
345
+ - **Домен портала** `BXRestFrame`, как и BX24.js, удостоверить не может — его задаёт тот, кто встроил страницу в iframe. Поэтому токен, пришедший из браузера, сервер должен проверить у портала, прежде чем ему доверять.
346
+
347
+ ### Рецепт: напрямую в портал
348
+
349
+ Токен хранит фронтенд, запросы идут в `https://<портал>/rest/`. Функция продления берёт новый токен у портала и кладёт его туда, откуда его читает `auth.source` — здесь это переменная в памяти:
350
+
351
+ ```typescript
352
+ import { BXRestSettings, BXRestFrame } from 'bx-rest'
353
+
354
+ const frame = new BXRestFrame()
355
+ const auth = await frame.getAuth()
356
+ let accessToken = auth.access_token
357
+
358
+ BXRestSettings.update({
359
+ auth: {
360
+ source: () => accessToken,
361
+ key: 'auth',
362
+ refresh: async () => {
363
+ accessToken = (await frame.refreshAuth()).access_token
364
+ }
365
+ },
366
+ urls: {
367
+ source: 'string',
368
+ key: `https://${auth.domain}`,
369
+ additional_part: 'rest'
370
+ }
371
+ })
372
+ ```
373
+
374
+ С `auth.source: 'localStorage'` функция продления так же записывает токен в `localStorage[auth.key]`.
375
+
376
+ ### Рецепт: через бэкенд-прокси
377
+
378
+ Токен хранит сервер, браузер авторизуется у прокси cookie-сессией (`auth.source: 'off'`). Прокси без действующего токена отвечает 401 с пустым телом — это ошибка авторизации по умолчанию. Функция продления берёт новый токен у портала и отправляет его на сервер:
379
+
380
+ ```typescript
381
+ import { BXRestSettings, BXRestFrame } from 'bx-rest'
382
+
383
+ const APP = 'smart-process-pro'
384
+ const frame = new BXRestFrame()
385
+
386
+ BXRestSettings.update({
387
+ auth: {
388
+ source: 'off',
389
+ key: 'auth',
390
+ refresh: async () => {
391
+ const { access_token, refresh_token, expires_in } = await frame.refreshAuth()
392
+ const response = await fetch(`/api/bitrix/token/${APP}`, {
393
+ method: 'POST',
394
+ credentials: 'same-origin',
395
+ headers: { 'Content-Type': 'application/json' },
396
+ body: JSON.stringify({ access_token, refresh_token, expires_in })
397
+ })
398
+ if (!response.ok) {
399
+ // без тела запроса в сообщении — в нём токены
400
+ throw new Error(`Token upload failed: HTTP ${response.status}`)
401
+ }
402
+ }
403
+ },
404
+ urls: {
405
+ source: 'string',
406
+ key: '/api/bitrix/rest/',
407
+ additional_part: '' // иначе к адресу добавится ещё один `rest/`
408
+ },
409
+ headers: {
410
+ 'X-Bitrix-App': APP
411
+ }
412
+ })
413
+ ```
414
+
415
+ Ошибки функции продления попадают в `authRefreshError` исходной ошибки, а `.res()` может вывести исходную ошибку в консоль — поэтому не кладите токены в текст ошибок функции продления.
416
+
417
+ ## Работа в iframe без BX24.js
418
+
419
+ `BXRestFrame` — команды, которые приложение из iframe отправляет порталу: размер фрейма, диалоги выбора, слайдеры, мессенджер, встраивания, настройки, установка. Те же команды и те же имена, что у BX24.js, но вместо колбэков — промисы, и подключать BX24.js не нужно. REST-запросы делает сама bx-rest, токен для них — `getAuth()`/`refreshAuth()` (см. [Продление авторизации](#продление-авторизации)).
420
+
421
+ ```typescript
422
+ import { BXRestFrame } from 'bx-rest'
423
+
424
+ const frame = new BXRestFrame()
425
+
426
+ const { lang, isAdmin, placement } = await frame.init()
427
+ await frame.fitWindow()
428
+ const user = await frame.selectUser()
429
+ const unsubscribe = frame.placement.bindEvent('Tab:onChange', data => console.log(data))
430
+ ```
431
+
432
+ Создавайте один экземпляр на страницу и при старте приложения: параметры портала читаются в конструкторе, а признак первого запуска портал сообщает только в первом ответе за загрузку страницы. По той же причине не используйте `BXRestFrame` и BX24.js на одной странице.
433
+
434
+ | BX24.js | `BXRestFrame` | Ответ |
435
+ | --- | --- | --- |
436
+ | `BX24.init()`, `isAdmin()`, `getLang()`, `getDomain()` | `init()` — `{ domain, lang, path, https, memberId, isAdmin, install, firstRun, placement, placementOptions, userOptions, appOptions }` | Запрашивается один раз, без токенов |
437
+ | `BX24.getAuth()`, `BX24.refreshAuth()` | `getAuth()`, `refreshAuth()` | Каждый раз у портала |
438
+ | `BX24.installFinish()` | `installFinish()` | Только на странице установщика (`install`); портал перезагружает страницу |
439
+ | конец цепочки `BX24.install()` | `firstRunFinish()` | Отмечает, что первый запуск пройден; без него `firstRun` останется `true` |
440
+ | `BX24.resizeWindow(w, h)`, `BX24.fitWindow()` | `resizeWindow(w, h)`, `fitWindow()` | `{ width, height }` или `undefined` |
441
+ | `BX24.setTitle(title)` | `setTitle(title)` | `{ title }` или `undefined` |
442
+ | `BX24.scrollParentWindow(scroll)` | `scrollParentWindow(scroll)` | `{ scroll }` или `undefined` |
443
+ | `BX24.reloadWindow()` | `reloadWindow()` | Без ответа |
444
+ | `BX24.selectUser()`, `BX24.selectUsers()` | `selectUser({ title?, signal? })`, `selectUsers(...)` | Сотрудник / массив сотрудников |
445
+ | `BX24.selectAccess(value)` | `selectAccess({ value?, signal? })` — `value`: коды, которые нельзя выбрать | Массив `{ provider, id, name }` |
446
+ | `BX24.selectCRM(params)` | `selectCRM({ entityType?, multiple?, value?, signal? })` | Объекты CRM по типам |
447
+ | `BX24.openApplication(params, cb, settings)` | `openApplication(params?, settings?, { signal? })` | Когда слайдер закрыли |
448
+ | `BX24.openPath(path)` | `openPath(path, { signal? })` | `{ result: 'close' }` или `{ result: 'error', errorCode }` |
449
+ | `BX24.closeApplication()` | `closeApplication()` | Без ответа |
450
+ | `BX24.im.callTo/phoneTo/openMessenger/openHistory` | `im.callTo/phoneTo/openMessenger/openHistory` | Без ответа |
451
+ | `BX24.placement.info()` | `placement.info()` | `{ placement, options }` из `init()` |
452
+ | `BX24.placement.getInterface()` | `placement.getInterface()` | `{ command, event }` |
453
+ | `BX24.placement.call(cmd, params)` | `placement.call(cmd, params?, { timeout? })` | Ответ команды или `undefined` |
454
+ | `BX24.placement.bindEvent(event, cb)` | `placement.bindEvent(event, handler)` | `handler` на каждое событие; возвращает функцию отписки |
455
+ | `BX24.userOption.get/set` | `userOption.get/set` | Настройка пользователя |
456
+ | `BX24.appOption.get/set` | `appOption.get/set` | `set` — только администратору, иначе `ACCESS_DENIED` |
457
+
458
+ Как портал отвечает на команды — от этого зависит, когда завершается промис:
459
+
460
+ - **С ответом** (`init()`, `getAuth()`, `refreshAuth()`, `placement.getInterface()`, `appOption.set()`) — нет ответа за `timeout` (по умолчанию 10 секунд) — ошибка `TIMEOUT`.
461
+ - **С подтверждением** (`resizeWindow()`, `fitWindow()`, `setTitle()`, `scrollParentWindow()`, `userOption.set()`, `firstRunFinish()`, `placement.call()`) — в некоторых встраиваниях портал на них не отвечает, поэтому без ответа за `timeout` промис завершается с `undefined`, а не ошибкой.
462
+ - **Без ответа** (`reloadWindow()`, `closeApplication()`, `installFinish()`, `im.*`) — промис завершается сразу после отправки.
463
+ - **Ждут пользователя** (`select*()`, `openApplication()`, `openPath()`) — без ограничения по времени. Если пользователь закрыл диалог, ничего не выбрав, портал не отвечает вовсе. Отменить ожидание можно через `signal` (`AbortController`), а повторный вызов того же диалога отменяет прошлый: портал держит один обработчик на диалог. Отменённое ожидание — ошибка `CANCELLED`.
464
+
465
+ `placement.bindEvent()` возвращает функцию отписки, но она только перестаёт вызывать `handler`: отменить подписку на стороне портала протокол не позволяет.
466
+
467
+ - **Где ищет портал.** Как BX24.js — в `window.name`: портал называет iframe приложения `DOMAIN|PROTOCOL|APP_SID`. Если там пусто — в query страницы (`?DOMAIN=…&PROTOCOL=…&APP_SID=…`), с которым портал открывает обработчик приложения.
468
+ - **Безопасность.** Команды уходят только на origin портала (не `'*'`), ответы принимаются только от `window.parent` и только с origin портала.
469
+ - **Ошибки** — `BXRestFrameError` с полем `code`: `NOT_IN_FRAME` (страница открыта не в iframe приложения — ошибка сразу), `TIMEOUT`, `NO_AUTH`, `BAD_RESPONSE`, `CANCELLED`, `ACCESS_DENIED`. Неверные аргументы `resizeWindow()` и `scrollParentWindow()` — `TypeError`.
470
+
471
+ > **Протокол внутренний и недокументированный.** Обмен сообщениями между приложением и порталом Bitrix24 не описан в документации и может измениться без предупреждения. `BXRestFrame` повторяет BX24.js — `https://api.bitrix24.com/api/v1/`, скачан 2026-10-29, `Last-Modified: 2022-03-17`, SHA-256 `945ad491df8ffb7490b89f4914a439b5a3f575c950c3480562815716a3e07f23` — и сверен с ответной стороной портала, `/bitrix/js/rest/applayout.js` (`Last-Modified: 2024-05-15`, SHA-256 `3efd6ab57a4778493a26d10ae0987a82e19fc012ed9935f5c6afa78c4052fcb5`) и с документацией BX24.js. Проверен на поддельном портале в тестах, но не на живом портале.
472
+
240
473
  ## Использование в Angular
241
474
 
242
475
  Зарегистрируйте публичные клиенты и `Navvy`, когда нужно внедрять их в сервисы или компоненты Angular: