itd-api 0.5.0 → 0.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { C as joinUrl, S as isSameSite, T as originOf, _ as REFRESH_COOKIE, a as scopedTokenStorage, b as encodePathSegment, c as isBoundedFileStream, d as optionalBytes, f as DEFAULT_FILE_STREAM_BUFFER_BYTES, g as CookieJar, h as AUTH_FLAG_COOKIE, i as isRestorableSession, l as isReadableByteStream, m as FileTransferMode, n as controlledTokenStorage, o as resolveFileStreamOptions, p as DEFAULT_URL_FILE_MAX_BYTES, r as createMultiTokenStorage, s as boundedFileStream, t as MemoryMultiTokenStorage, u as fileTooLarge, v as REFRESH_COOKIE_PATH, w as normalizeBaseUrl, x as hostOf, y as buildQuery } from "./multi-storage-B0r0AInH.js";
2
- import { A as isItdApiError, B as isItdStateError, C as ItdNotFoundError, D as ItdStateError, E as ItdServerError, F as isItdForbiddenError, I as isItdNotFoundError, L as isItdPhoneVerificationError, M as isItdConflictError, N as isItdError, O as ItdTimeoutError, P as isItdFileError, R as isItdRateLimitError, S as ItdNetworkError, T as ItdRateLimitError, V as isItdValidationError, _ as ItdError, b as ItdFileErrorReason, c as isEnumerableKeyValueStore, d as ItdAbortError, f as ItdApiError, g as ItdConflictError, h as ItdConfigError, i as MemoryKeyValueStore, j as isItdAuthError, k as ItdValidationError, l as withCodec, m as ItdAuthError, n as copySession, o as createKeyValueStore, p as ItdApiErrorKind, r as createTokenStorage, s as createRecordKeyValueStore, t as MemoryTokenStorage, u as withNamespace, v as ItdErrorKind, w as ItdPhoneVerificationError, x as ItdForbiddenError, y as ItdFileError, z as isItdServerError } from "./storage-DWrK3Z4M.js";
1
+ import { C as normalizeBaseUrl, S as joinUrl, _ as REFRESH_COOKIE_PATH, a as scopedTokenStorage, b as hostOf, c as isBoundedFileStream, d as optionalBytes, f as DEFAULT_URL_FILE_MAX_BYTES, g as REFRESH_COOKIE, h as CookieJar, i as isRestorableSession, l as isReadableByteStream, n as controlledTokenStorage, o as resolveFileStreamOptions, p as FileTransferMode, r as createMultiTokenStorage, s as boundedFileStream, t as MemoryMultiTokenStorage, u as fileTooLarge, v as buildQuery, w as originOf, x as isSameSite, y as encodePathSegment } from "./multi-storage-BUZaLAPO.js";
2
+ import { A as isItdApiError, B as isItdStateError, C as ItdNotFoundError, D as ItdStateError, E as ItdServerError, F as isItdForbiddenError, I as isItdNotFoundError, L as isItdPhoneVerificationError, M as isItdConflictError, N as isItdError, O as ItdTimeoutError, P as isItdFileError, R as isItdRateLimitError, S as ItdNetworkError, T as ItdRateLimitError, V as isItdValidationError, _ as ItdError, b as ItdFileErrorReason, c as isEnumerableKeyValueStore, d as ItdAbortError, f as ItdApiError, g as ItdConflictError, h as ItdConfigError, i as MemoryKeyValueStore, j as isItdAuthError, k as ItdValidationError, l as withCodec, m as ItdAuthError, n as copySession, o as createKeyValueStore, p as ItdApiErrorKind, r as createTokenStorage, s as createRecordKeyValueStore, t as MemoryTokenStorage, u as withNamespace, v as ItdErrorKind, w as ItdPhoneVerificationError, x as ItdForbiddenError, y as ItdFileError, z as isItdServerError } from "./storage-IHdXw52v.js";
3
3
  //#region src/core/emitter.ts
4
4
  /**
5
5
  * Минимальный типизированный источник событий.
@@ -129,6 +129,56 @@ function readTokenSubject(token) {
129
129
  return readTokenIdentity(token).subject;
130
130
  }
131
131
  //#endregion
132
+ //#region src/core/buckets.ts
133
+ /**
134
+ * Ёмкость серверных счётчиков частоты, запросов в минуту.
135
+ *
136
+ * Таблица действует до первого ответа бакета; дальше ёмкость берётся из заголовка
137
+ * `x-ratelimit-limit` и заменяет табличную. `default` — счётчик любого пути без
138
+ * собственного правила на сервере.
139
+ */
140
+ const BUCKET_LIMITS = Object.freeze({
141
+ "posts.stats": 180,
142
+ default: 150,
143
+ feed: 90,
144
+ "posts.like": 85,
145
+ "posts.comments": 80,
146
+ hashtags: 50,
147
+ users: 40,
148
+ notifications: 40,
149
+ "files.get": 40,
150
+ auth: 35,
151
+ "auth.refresh": 25,
152
+ search: 25,
153
+ "comments.like": 22,
154
+ "files.upload": 15,
155
+ "files.remove": 15,
156
+ "posts.comment": 14,
157
+ "hashtags.trending": 13,
158
+ "posts.repost": 7,
159
+ "users.follow": 7,
160
+ "verification.status": 6,
161
+ "posts.create": 5,
162
+ "users.updateMe": 3,
163
+ "reports.create": 3,
164
+ "verification.submit": 3
165
+ });
166
+ /** Счётчик, из которого списывается путь без собственного правила на сервере. */
167
+ const DEFAULT_RATE_LIMIT_BUCKET = "default";
168
+ /** Известно ли библиотеке имя бакета. */
169
+ function isKnownBucket(name) {
170
+ return Object.hasOwn(BUCKET_LIMITS, name);
171
+ }
172
+ /** Реакция на остаток лимита из заголовков ответа. */
173
+ const RateLimitPacing = Object.freeze({
174
+ /** Задержек нет, пока в бакете есть остаток; исчерпанный бакет ждёт `60000 / limit`. */
175
+ React: "react",
176
+ /** Ровный темп в пределах минутного лимита: задержки идут с первого запроса. */
177
+ Smooth: "smooth",
178
+ /** Остаток на темп не влияет; остаётся пауза после `429`. */
179
+ Off: "off"
180
+ });
181
+ //#endregion
132
182
  //#region src/core/operations.ts
133
183
  /** Семантическая безопасность автоматического повтора операции. */
134
184
  const RetrySafety = Object.freeze({
@@ -156,59 +206,73 @@ const OPERATIONS = freezeOperations({
156
206
  },
157
207
  "auth.signUp": {
158
208
  method: "POST",
159
- retrySafety: RetrySafety.Unsafe
209
+ retrySafety: RetrySafety.Unsafe,
210
+ bucket: "auth"
160
211
  },
161
212
  "auth.signIn": {
162
213
  method: "POST",
163
- retrySafety: RetrySafety.Safe
214
+ retrySafety: RetrySafety.Safe,
215
+ bucket: "auth"
164
216
  },
165
217
  "auth.verifyOtp": {
166
218
  method: "POST",
167
- retrySafety: RetrySafety.Unsafe
219
+ retrySafety: RetrySafety.Unsafe,
220
+ bucket: "auth"
168
221
  },
169
222
  "auth.resendOtp": {
170
223
  method: "POST",
171
- retrySafety: RetrySafety.Unsafe
224
+ retrySafety: RetrySafety.Unsafe,
225
+ bucket: "auth"
172
226
  },
173
227
  "auth.refresh": {
174
228
  method: "POST",
175
- retrySafety: RetrySafety.Unsafe
229
+ retrySafety: RetrySafety.Unsafe,
230
+ bucket: "auth.refresh"
176
231
  },
177
232
  "auth.logout": {
178
233
  method: "POST",
179
- retrySafety: RetrySafety.Unsafe
234
+ retrySafety: RetrySafety.Unsafe,
235
+ bucket: "auth"
180
236
  },
181
237
  "auth.forgotPassword": {
182
238
  method: "POST",
183
- retrySafety: RetrySafety.Unsafe
239
+ retrySafety: RetrySafety.Unsafe,
240
+ bucket: "auth"
184
241
  },
185
242
  "auth.resetPassword": {
186
243
  method: "POST",
187
- retrySafety: RetrySafety.Unsafe
244
+ retrySafety: RetrySafety.Unsafe,
245
+ bucket: "auth"
188
246
  },
189
247
  "auth.changePassword": {
190
248
  method: "POST",
191
- retrySafety: RetrySafety.Unsafe
249
+ retrySafety: RetrySafety.Unsafe,
250
+ bucket: "auth"
192
251
  },
193
252
  "auth.sessions": {
194
253
  method: "GET",
195
- retrySafety: RetrySafety.Safe
254
+ retrySafety: RetrySafety.Safe,
255
+ bucket: "auth"
196
256
  },
197
257
  "auth.revokeSession": {
198
258
  method: "DELETE",
199
- retrySafety: RetrySafety.Unsafe
259
+ retrySafety: RetrySafety.Unsafe,
260
+ bucket: "auth"
200
261
  },
201
262
  "auth.revokeOtherSessions": {
202
263
  method: "DELETE",
203
- retrySafety: RetrySafety.Unsafe
264
+ retrySafety: RetrySafety.Unsafe,
265
+ bucket: "auth"
204
266
  },
205
267
  "users.me": {
206
268
  method: "GET",
207
- retrySafety: RetrySafety.Safe
269
+ retrySafety: RetrySafety.Safe,
270
+ bucket: "users"
208
271
  },
209
272
  "users.updateMe": {
210
273
  method: "PUT",
211
- retrySafety: RetrySafety.Idempotent
274
+ retrySafety: RetrySafety.Idempotent,
275
+ bucket: "users.updateMe"
212
276
  },
213
277
  "users.deactivate": {
214
278
  method: "DELETE",
@@ -224,39 +288,48 @@ const OPERATIONS = freezeOperations({
224
288
  },
225
289
  "users.get": {
226
290
  method: "GET",
227
- retrySafety: RetrySafety.Safe
291
+ retrySafety: RetrySafety.Safe,
292
+ bucket: "users"
228
293
  },
229
294
  "users.checkUsername": {
230
295
  method: "GET",
231
- retrySafety: RetrySafety.Safe
296
+ retrySafety: RetrySafety.Safe,
297
+ bucket: "users"
232
298
  },
233
299
  "users.search": {
234
300
  method: "GET",
235
- retrySafety: RetrySafety.Safe
301
+ retrySafety: RetrySafety.Safe,
302
+ bucket: "users"
236
303
  },
237
304
  "users.whoToFollow": {
238
305
  method: "GET",
239
- retrySafety: RetrySafety.Safe
306
+ retrySafety: RetrySafety.Safe,
307
+ bucket: "users"
240
308
  },
241
309
  "users.topClans": {
242
310
  method: "GET",
243
- retrySafety: RetrySafety.Safe
311
+ retrySafety: RetrySafety.Safe,
312
+ bucket: "users"
244
313
  },
245
314
  "users.follow": {
246
315
  method: "POST",
247
- retrySafety: RetrySafety.Unsafe
316
+ retrySafety: RetrySafety.Unsafe,
317
+ bucket: "users.follow"
248
318
  },
249
319
  "users.unfollow": {
250
320
  method: "DELETE",
251
- retrySafety: RetrySafety.Unsafe
321
+ retrySafety: RetrySafety.Unsafe,
322
+ bucket: "users.follow"
252
323
  },
253
324
  "users.followers": {
254
325
  method: "GET",
255
- retrySafety: RetrySafety.Safe
326
+ retrySafety: RetrySafety.Safe,
327
+ bucket: "users"
256
328
  },
257
329
  "users.following": {
258
330
  method: "GET",
259
- retrySafety: RetrySafety.Safe
331
+ retrySafety: RetrySafety.Safe,
332
+ bucket: "users"
260
333
  },
261
334
  "users.followStatus": {
262
335
  method: "POST",
@@ -272,11 +345,13 @@ const OPERATIONS = freezeOperations({
272
345
  },
273
346
  "users.blocked": {
274
347
  method: "GET",
275
- retrySafety: RetrySafety.Safe
348
+ retrySafety: RetrySafety.Safe,
349
+ bucket: "users"
276
350
  },
277
351
  "users.getPrivacy": {
278
352
  method: "GET",
279
- retrySafety: RetrySafety.Safe
353
+ retrySafety: RetrySafety.Safe,
354
+ bucket: "users"
280
355
  },
281
356
  "users.updatePrivacy": {
282
357
  method: "PUT",
@@ -284,7 +359,8 @@ const OPERATIONS = freezeOperations({
284
359
  },
285
360
  "users.pins": {
286
361
  method: "GET",
287
- retrySafety: RetrySafety.Safe
362
+ retrySafety: RetrySafety.Safe,
363
+ bucket: "users"
288
364
  },
289
365
  "users.setPin": {
290
366
  method: "PUT",
@@ -296,11 +372,13 @@ const OPERATIONS = freezeOperations({
296
372
  },
297
373
  "posts.list": {
298
374
  method: "GET",
299
- retrySafety: RetrySafety.Safe
375
+ retrySafety: RetrySafety.Safe,
376
+ bucket: "feed"
300
377
  },
301
378
  "posts.create": {
302
379
  method: "POST",
303
- retrySafety: RetrySafety.Unsafe
380
+ retrySafety: RetrySafety.Unsafe,
381
+ bucket: "posts.create"
304
382
  },
305
383
  "posts.get": {
306
384
  method: "GET",
@@ -320,19 +398,23 @@ const OPERATIONS = freezeOperations({
320
398
  },
321
399
  "posts.like": {
322
400
  method: "POST",
323
- retrySafety: RetrySafety.Unsafe
401
+ retrySafety: RetrySafety.Unsafe,
402
+ bucket: "posts.like"
324
403
  },
325
404
  "posts.unlike": {
326
405
  method: "DELETE",
327
- retrySafety: RetrySafety.Unsafe
406
+ retrySafety: RetrySafety.Unsafe,
407
+ bucket: "posts.like"
328
408
  },
329
409
  "posts.repost": {
330
410
  method: "POST",
331
- retrySafety: RetrySafety.Unsafe
411
+ retrySafety: RetrySafety.Unsafe,
412
+ bucket: "posts.repost"
332
413
  },
333
414
  "posts.unrepost": {
334
415
  method: "DELETE",
335
- retrySafety: RetrySafety.Unsafe
416
+ retrySafety: RetrySafety.Unsafe,
417
+ bucket: "posts.repost"
336
418
  },
337
419
  "posts.pin": {
338
420
  method: "POST",
@@ -348,7 +430,8 @@ const OPERATIONS = freezeOperations({
348
430
  },
349
431
  "posts.stats": {
350
432
  method: "POST",
351
- retrySafety: RetrySafety.Safe
433
+ retrySafety: RetrySafety.Safe,
434
+ bucket: "posts.stats"
352
435
  },
353
436
  "posts.byUser": {
354
437
  method: "GET",
@@ -360,11 +443,13 @@ const OPERATIONS = freezeOperations({
360
443
  },
361
444
  "posts.comments": {
362
445
  method: "GET",
363
- retrySafety: RetrySafety.Safe
446
+ retrySafety: RetrySafety.Safe,
447
+ bucket: "posts.comments"
364
448
  },
365
449
  "posts.comment": {
366
450
  method: "POST",
367
- retrySafety: RetrySafety.Unsafe
451
+ retrySafety: RetrySafety.Unsafe,
452
+ bucket: "posts.comment"
368
453
  },
369
454
  "comments.replies": {
370
455
  method: "GET",
@@ -388,31 +473,38 @@ const OPERATIONS = freezeOperations({
388
473
  },
389
474
  "comments.like": {
390
475
  method: "POST",
391
- retrySafety: RetrySafety.Unsafe
476
+ retrySafety: RetrySafety.Unsafe,
477
+ bucket: "comments.like"
392
478
  },
393
479
  "comments.unlike": {
394
480
  method: "DELETE",
395
- retrySafety: RetrySafety.Unsafe
481
+ retrySafety: RetrySafety.Unsafe,
482
+ bucket: "comments.like"
396
483
  },
397
484
  "files.upload": {
398
485
  method: "POST",
399
- retrySafety: RetrySafety.Unsafe
486
+ retrySafety: RetrySafety.Unsafe,
487
+ bucket: "files.upload"
400
488
  },
401
489
  "files.get": {
402
490
  method: "GET",
403
- retrySafety: RetrySafety.Safe
491
+ retrySafety: RetrySafety.Safe,
492
+ bucket: "files.get"
404
493
  },
405
494
  "files.remove": {
406
495
  method: "DELETE",
407
- retrySafety: RetrySafety.Unsafe
496
+ retrySafety: RetrySafety.Unsafe,
497
+ bucket: "files.remove"
408
498
  },
409
499
  "notifications.list": {
410
500
  method: "GET",
411
- retrySafety: RetrySafety.Safe
501
+ retrySafety: RetrySafety.Safe,
502
+ bucket: "notifications"
412
503
  },
413
504
  "notifications.count": {
414
505
  method: "GET",
415
- retrySafety: RetrySafety.Safe
506
+ retrySafety: RetrySafety.Safe,
507
+ bucket: "notifications"
416
508
  },
417
509
  "notifications.markRead": {
418
510
  method: "POST",
@@ -428,31 +520,47 @@ const OPERATIONS = freezeOperations({
428
520
  },
429
521
  "notifications.getSettings": {
430
522
  method: "GET",
431
- retrySafety: RetrySafety.Safe
523
+ retrySafety: RetrySafety.Safe,
524
+ bucket: "notifications"
432
525
  },
433
526
  "notifications.updateSettings": {
434
527
  method: "PUT",
435
528
  retrySafety: RetrySafety.Idempotent
436
529
  },
530
+ "realtime.poll.updates": {
531
+ method: "GET",
532
+ retrySafety: RetrySafety.Safe,
533
+ bucket: "notifications"
534
+ },
535
+ "realtime.poll.unread": {
536
+ method: "GET",
537
+ retrySafety: RetrySafety.Safe,
538
+ bucket: "notifications"
539
+ },
437
540
  "hashtags.search": {
438
541
  method: "GET",
439
- retrySafety: RetrySafety.Safe
542
+ retrySafety: RetrySafety.Safe,
543
+ bucket: "hashtags"
440
544
  },
441
545
  "hashtags.trending": {
442
546
  method: "GET",
443
- retrySafety: RetrySafety.Safe
547
+ retrySafety: RetrySafety.Safe,
548
+ bucket: "hashtags.trending"
444
549
  },
445
550
  "hashtags.posts": {
446
551
  method: "GET",
447
- retrySafety: RetrySafety.Safe
552
+ retrySafety: RetrySafety.Safe,
553
+ bucket: "hashtags"
448
554
  },
449
555
  "search.all": {
450
556
  method: "GET",
451
- retrySafety: RetrySafety.Safe
557
+ retrySafety: RetrySafety.Safe,
558
+ bucket: "search"
452
559
  },
453
560
  "reports.create": {
454
561
  method: "POST",
455
- retrySafety: RetrySafety.Unsafe
562
+ retrySafety: RetrySafety.Unsafe,
563
+ bucket: "reports.create"
456
564
  },
457
565
  "subscription.status": {
458
566
  method: "GET",
@@ -484,11 +592,13 @@ const OPERATIONS = freezeOperations({
484
592
  },
485
593
  "verification.status": {
486
594
  method: "GET",
487
- retrySafety: RetrySafety.Safe
595
+ retrySafety: RetrySafety.Safe,
596
+ bucket: "verification.status"
488
597
  },
489
598
  "verification.submit": {
490
599
  method: "POST",
491
- retrySafety: RetrySafety.Unsafe
600
+ retrySafety: RetrySafety.Unsafe,
601
+ bucket: "verification.submit"
492
602
  },
493
603
  "platform.version": {
494
604
  method: "GET",
@@ -532,6 +642,16 @@ function operationMethod(id) {
532
642
  function operationRetrySafety(id) {
533
643
  return OPERATIONS[id].retrySafety;
534
644
  }
645
+ /**
646
+ * Бакет операции.
647
+ *
648
+ * `raw` и `custom:*` попадают в `default`; назвать бакет явно позволяет
649
+ * `rateLimitBucket` у запроса.
650
+ */
651
+ function operationBucket(id) {
652
+ if (!isBuiltInOperationId(id)) return DEFAULT_RATE_LIMIT_BUCKET;
653
+ return OPERATIONS[id].bucket ?? "default";
654
+ }
535
655
  //#endregion
536
656
  //#region src/core/runtime.ts
537
657
  /**
@@ -667,6 +787,11 @@ const AUTH_PATHS = {
667
787
  * Нужен, чтобы отрисовать виджет капчи и получить токен для `signIn`, `signUp`
668
788
  * и `forgotPassword`.
669
789
  *
790
+ * Ключ привязан к домену: на чужом origin Cloudflare отказывает виджету с кодом `110200`.
791
+ * Поэтому отрисовать его может только код, выполняемый на самом итд.com. Остальным
792
+ * подходит `@itd-api/turnstile`, готовый токен из другого источника или вовсе вход
793
+ * без капчи — по сохранённой сессии либо по токенам, взятым в браузере.
794
+ *
670
795
  * @example
671
796
  * ```ts
672
797
  * turnstile.render('#captcha', {
@@ -676,8 +801,6 @@ const AUTH_PATHS = {
676
801
  * ```
677
802
  */
678
803
  const TURNSTILE_SITE_KEY = "0x4AAAAAACHhxczw6fJGwPBg";
679
- /** Заголовок с идентификатором устройства. Сервер связывает с ним запись в списке сессий. */
680
- const DEVICE_ID_HEADER = "X-Device-Id";
681
804
  function readAccessToken(payload) {
682
805
  if (typeof payload !== "object" || payload === null) return void 0;
683
806
  const token = payload.accessToken;
@@ -1182,10 +1305,33 @@ const systemClock = Object.freeze({
1182
1305
  return () => clearTimeout(timer);
1183
1306
  }
1184
1307
  });
1308
+ /**
1309
+ * Заводит срок, общий на все ожидания: от ожидания к ожиданию он не продлевается.
1310
+ *
1311
+ * @param timeout срок в миллисекундах; `0` — ждать без ограничения
1312
+ * @internal
1313
+ */
1314
+ function createDeadline(timeout, clock = systemClock) {
1315
+ if (timeout <= 0) return {
1316
+ wait: async (promise) => {
1317
+ await promise;
1318
+ return true;
1319
+ },
1320
+ cancel: () => {}
1321
+ };
1322
+ let expire;
1323
+ const expired = new Promise((resolve) => {
1324
+ expire = resolve;
1325
+ });
1326
+ return {
1327
+ wait: (promise) => Promise.race([promise.then(() => true), expired.then(() => false)]),
1328
+ cancel: clock.schedule(() => expire(), timeout)
1329
+ };
1330
+ }
1185
1331
  //#endregion
1186
1332
  //#region src/core/version.ts
1187
1333
  /** Версия библиотеки. Попадает в `User-Agent`. */
1188
- const LIBRARY_VERSION = "0.5.0";
1334
+ const LIBRARY_VERSION = "0.7.0";
1189
1335
  //#endregion
1190
1336
  //#region src/core/config.ts
1191
1337
  /** Базовый URL API итд.com. Домен записан в punycode: `итд.com`. */
@@ -1200,8 +1346,6 @@ const BUILT_IN_SERVICES = Object.freeze([Object.freeze({
1200
1346
  baseUrl: DEFAULT_STATUS_BASE_URL,
1201
1347
  auth: false
1202
1348
  })]);
1203
- /** Таймаут запроса по умолчанию — 30 секунд. */
1204
- const DEFAULT_TIMEOUT = 3e4;
1205
1349
  /**
1206
1350
  * `User-Agent` по умолчанию.
1207
1351
  *
@@ -1216,9 +1360,8 @@ const DEFAULT_USER_AGENT = `Mozilla/5.0 (compatible; itd-api/${LIBRARY_VERSION};
1216
1360
  /**
1217
1361
  * Паузы перед повторами при ответе `429`.
1218
1362
  *
1219
- * Сервер итд.com не присылает `Retry-After` и не сообщает время сброса окна, поэтому
1220
- * паузу приходится подбирать лестницей: от секунды, если окно почти истекло,
1221
- * до полутора минут, если лимит исчерпан всерьёз.
1363
+ * Сервер итд.com не присылает `Retry-After` и не сообщает время сброса окна, поэтому паузу
1364
+ * приходится подбирать лестницей: от секунды, если окно почти истекло, до полутора минут.
1222
1365
  */
1223
1366
  const DEFAULT_RATE_LIMIT_DELAYS = Object.freeze([
1224
1367
  1e3,
@@ -1227,6 +1370,13 @@ const DEFAULT_RATE_LIMIT_DELAYS = Object.freeze([
1227
1370
  6e4,
1228
1371
  9e4
1229
1372
  ]);
1373
+ /**
1374
+ * Встроенные поправки бакетов.
1375
+ *
1376
+ * Таймаут загрузки файла — пять минут против обычных тридцати секунд, поэтому её
1377
+ * одновременность ограничена одним запросом.
1378
+ */
1379
+ const DEFAULT_BUCKET_OVERRIDES = Object.freeze({ "files.upload": Object.freeze({ concurrency: 1 }) });
1230
1380
  function requirePositive(value, name) {
1231
1381
  if (!Number.isFinite(value) || value < 0) throw new ItdConfigError(`${name} должен быть неотрицательным числом, получено: ${value}`);
1232
1382
  return value;
@@ -1315,6 +1465,54 @@ function resolveRetry(retry) {
1315
1465
  };
1316
1466
  }
1317
1467
  /**
1468
+ * Сверяет имя бакета со встроенной картой.
1469
+ *
1470
+ * Своё правило выбора заводит собственное пространство имён, и встроенные имена в нём
1471
+ * необязательны — тогда проверка снимается.
1472
+ *
1473
+ * @param option путь опции для текста ошибки
1474
+ * @throws {ItdConfigError} если имени нет во встроенной карте
1475
+ *
1476
+ * @internal
1477
+ */
1478
+ function assertKnownBucket(name, option, bucket) {
1479
+ if (bucket !== void 0 || isKnownBucket(name)) return;
1480
+ throw new ItdConfigError(`${option}: бакета «${name}» нет. Известны: ${Object.keys(BUCKET_LIMITS).join(", ")}`);
1481
+ }
1482
+ function requireConcurrency(value, name) {
1483
+ if (!Number.isInteger(value) || value < 1) throw new ItdConfigError(`${name} должен быть целым числом от 1, получено: ${value}`);
1484
+ return value;
1485
+ }
1486
+ function resolvePacing(pacing) {
1487
+ const known = Object.values(RateLimitPacing);
1488
+ if (pacing !== void 0 && !known.includes(pacing)) throw new ItdConfigError(`rateLimit.pacing должен быть одним из ${known.join(", ")}, получено: ${pacing}`);
1489
+ return pacing ?? RateLimitPacing.React;
1490
+ }
1491
+ /**
1492
+ * Проверяет поправки бакетов и накладывает их на встроенные.
1493
+ *
1494
+ * Имена сверяются со встроенной картой, пока не задано своё правило выбора бакета.
1495
+ * Поля сливаются по отдельности: своя ёмкость `files.upload` не снимает встроенный
1496
+ * предел одновременности.
1497
+ */
1498
+ function resolveBucketOverrides(overrides, bucket) {
1499
+ if (overrides === void 0) return DEFAULT_BUCKET_OVERRIDES;
1500
+ if (!isRecord$1(overrides)) throw new ItdConfigError("rateLimit.bucketOverrides должен быть объектом");
1501
+ const resolved = { ...DEFAULT_BUCKET_OVERRIDES };
1502
+ for (const [name, override] of Object.entries(overrides)) {
1503
+ if (!isRecord$1(override)) throw new ItdConfigError(`rateLimit.bucketOverrides.${name} должен быть объектом`);
1504
+ assertKnownBucket(name, "rateLimit.bucketOverrides", bucket);
1505
+ if (override.concurrency !== void 0) requireConcurrency(override.concurrency, `rateLimit.bucketOverrides.${name}.concurrency`);
1506
+ if (override.limit !== void 0 && (!Number.isFinite(override.limit) || override.limit <= 0)) throw new ItdConfigError(`rateLimit.bucketOverrides.${name}.limit должен быть положительным числом, получено: ${override.limit}`);
1507
+ const built = DEFAULT_BUCKET_OVERRIDES[name];
1508
+ resolved[name] = Object.freeze({
1509
+ concurrency: override.concurrency ?? built?.concurrency,
1510
+ limit: override.limit ?? built?.limit
1511
+ });
1512
+ }
1513
+ return Object.freeze(resolved);
1514
+ }
1515
+ /**
1318
1516
  * Приводит настройки очереди к полному виду. `undefined` — очередь не нужна.
1319
1517
  *
1320
1518
  * Кроме создания клиента вызывается ещё из {@link ItdAccounts}: общая на всех аккаунтов
@@ -1331,21 +1529,29 @@ function resolveRateLimit(rateLimit) {
1331
1529
  concurrency: 6,
1332
1530
  rps: void 0,
1333
1531
  retryDelays: DEFAULT_RATE_LIMIT_DELAYS,
1334
- respectHeaders: true
1532
+ buckets: true,
1533
+ pacing: RateLimitPacing.React,
1534
+ bucketConcurrency: 6,
1535
+ bucketOverrides: DEFAULT_BUCKET_OVERRIDES,
1536
+ bucket: void 0
1335
1537
  };
1336
1538
  if (!rateLimit) return defaults;
1337
- const concurrency = rateLimit.concurrency ?? 6;
1338
- if (!Number.isInteger(concurrency) || concurrency < 1) throw new ItdConfigError(`rateLimit.concurrency должен быть целым числом от 1, получено: ${concurrency}`);
1539
+ const concurrency = requireConcurrency(rateLimit.concurrency ?? 6, "rateLimit.concurrency");
1339
1540
  if (rateLimit.rps !== void 0 && (!Number.isFinite(rateLimit.rps) || rateLimit.rps <= 0)) throw new ItdConfigError(`rateLimit.rps должен быть положительным числом, получено: ${rateLimit.rps}`);
1340
1541
  const retryDelays = rateLimit.retryDelays ?? defaults.retryDelays;
1341
1542
  if (!Array.isArray(retryDelays)) throw new ItdConfigError("rateLimit.retryDelays должен быть массивом чисел");
1342
1543
  for (const delay of retryDelays) requirePositive(delay, "rateLimit.retryDelays");
1343
- requireOptionalBoolean(rateLimit.respectHeaders, "rateLimit.respectHeaders");
1544
+ requireOptionalBoolean(rateLimit.buckets, "rateLimit.buckets");
1545
+ if (rateLimit.bucket !== void 0 && typeof rateLimit.bucket !== "function") throw new ItdConfigError("rateLimit.bucket должен быть функцией");
1344
1546
  return {
1345
1547
  concurrency,
1346
1548
  rps: rateLimit.rps,
1347
1549
  retryDelays: [...retryDelays],
1348
- respectHeaders: rateLimit.respectHeaders ?? true
1550
+ buckets: rateLimit.buckets ?? true,
1551
+ pacing: resolvePacing(rateLimit.pacing),
1552
+ bucketConcurrency: requireConcurrency(rateLimit.bucketConcurrency ?? concurrency, "rateLimit.bucketConcurrency"),
1553
+ bucketOverrides: resolveBucketOverrides(rateLimit.bucketOverrides, rateLimit.bucket),
1554
+ bucket: rateLimit.bucket
1349
1555
  };
1350
1556
  }
1351
1557
  /**
@@ -1411,6 +1617,7 @@ function resolveConfig(options = {}) {
1411
1617
  const mode = options.mode ?? RuntimeMode.Auto;
1412
1618
  if (!Object.values(RuntimeMode).includes(mode)) throw new ItdConfigError(`mode должен быть одним из ${Object.values(RuntimeMode).join(", ")}, получено: ${mode}`);
1413
1619
  const timeout = requirePositive(options.timeout ?? 3e4, "timeout");
1620
+ const shutdownTimeout = requirePositive(options.shutdownTimeout ?? 1e4, "shutdownTimeout");
1414
1621
  if (options.clock !== void 0 && (typeof options.clock !== "object" || options.clock === null || typeof options.clock.now !== "function" || typeof options.clock.schedule !== "function")) throw new ItdConfigError("clock должен предоставлять методы now() и schedule()");
1415
1622
  requireOptionalBoolean(options.autoRefresh, "autoRefresh");
1416
1623
  requireOptionalBoolean(options.reloginOnRefreshFailure, "reloginOnRefreshFailure");
@@ -1426,6 +1633,7 @@ function resolveConfig(options = {}) {
1426
1633
  fetch: resolveFetch(options.fetch),
1427
1634
  clock: options.clock ?? systemClock,
1428
1635
  timeout,
1636
+ shutdownTimeout,
1429
1637
  retry: resolveRetry(options.retry),
1430
1638
  rateLimit: resolveRateLimit(options.rateLimit),
1431
1639
  hooks: resolveHooks(options.hooks),
@@ -1441,6 +1649,7 @@ function resolveConfig(options = {}) {
1441
1649
  //#endregion
1442
1650
  //#region src/core/pipeline.ts
1443
1651
  const REQUEST_ATTEMPT_STATE = Symbol("itd-api.request-attempt-state");
1652
+ const REQUEST_QUEUE_KEY = Symbol("itd-api.request-queue-key");
1444
1653
  const DISPOSE_CLEANUP_REQUEST = Symbol("itd-api.dispose-cleanup-request");
1445
1654
  /** Один раз присваивает низкоуровневому запросу семантический ID до входа в middleware. */
1446
1655
  function identifyRequest(request) {
@@ -1472,6 +1681,23 @@ function beginTransportAttempt(request) {
1472
1681
  function currentTransportAttempt(request) {
1473
1682
  return request[REQUEST_ATTEMPT_STATE]?.value ?? 0;
1474
1683
  }
1684
+ /**
1685
+ * Вычисляет ключ очереди один раз на логическую операцию.
1686
+ *
1687
+ * Ключ спрашивают трижды: при постановке в очередь, при чтении заголовков ответа и при
1688
+ * паузе после `429`. Значение пишется прямо в объект запроса — слои ниже копируют его
1689
+ * через spread, и перечислимое символьное поле переходит в копии.
1690
+ *
1691
+ * @internal
1692
+ */
1693
+ function requestQueueKey(request, compute) {
1694
+ const internal = request;
1695
+ const cached = internal[REQUEST_QUEUE_KEY];
1696
+ if (cached) return cached;
1697
+ const key = compute(request);
1698
+ internal[REQUEST_QUEUE_KEY] = key;
1699
+ return key;
1700
+ }
1475
1701
  /** Помечает запрос как часть внутренней финализации уже начатого `dispose()`. @internal */
1476
1702
  function markDisposeCleanupRequest(request) {
1477
1703
  return {
@@ -1816,7 +2042,7 @@ function createRetryMiddleware(deps) {
1816
2042
  const transportAttempt = currentTransportAttempt(trackedRequest);
1817
2043
  if (isItdRateLimitError(error)) rateLimitAttempt += 1;
1818
2044
  else retryAttempt += 1;
1819
- const delay = nextDelay(error, retryAttempt, rateLimitAttempt, request, policy, backoff);
2045
+ const delay = nextDelay(error, retryAttempt, rateLimitAttempt, trackedRequest, policy, backoff);
1820
2046
  if (delay === void 0) throw error;
1821
2047
  await dispatchRequestHook(deps.hooks, "onRetry", {
1822
2048
  operationId: request.operationId,
@@ -1994,7 +2220,11 @@ var PluginRegistry = class {
1994
2220
  #entries = /* @__PURE__ */ new Map();
1995
2221
  #removing = /* @__PURE__ */ new Set();
1996
2222
  #cleanups = /* @__PURE__ */ new Set();
2223
+ #options;
1997
2224
  #ordered = [];
2225
+ constructor(options) {
2226
+ this.#options = options;
2227
+ }
1998
2228
  /** Сколько плагинов подключено. */
1999
2229
  get size() {
2000
2230
  return this.#entries.size;
@@ -2068,9 +2298,11 @@ var PluginRegistry = class {
2068
2298
  * Отключает плагин и вызывает его функцию очистки.
2069
2299
  *
2070
2300
  * Новые запросы перестают видеть расширения плагина сразу. Снимок уже начавшейся операции,
2071
- * включая её будущие retry, остаётся неизменным; очистка дождётся завершения операции.
2301
+ * включая её будущие retry, остаётся неизменным; очистка дождётся завершения операции,
2302
+ * но не дольше отведённого срока.
2072
2303
  *
2073
2304
  * @returns `false`, если такого плагина не было
2305
+ * @throws {ItdStateError} если операции плагина не завершились за отведённый срок
2074
2306
  */
2075
2307
  async remove(name) {
2076
2308
  const entry = this.#entries.get(name);
@@ -2079,21 +2311,24 @@ var PluginRegistry = class {
2079
2311
  this.#entries.delete(name);
2080
2312
  this.#ordered = this.#ordered.filter((current) => current !== entry);
2081
2313
  this.#removing.add(name);
2314
+ const deadline = createDeadline(this.#options.shutdownTimeout, this.#options.clock);
2082
2315
  const cleanup = this.#trackCleanup((async () => {
2083
- await this.#waitForDrain(entry);
2084
- await entry.teardown?.();
2316
+ const expired = await this.#release(entry, deadline);
2317
+ if (expired) throw expired;
2085
2318
  })());
2086
2319
  try {
2087
2320
  await cleanup;
2088
2321
  return true;
2089
2322
  } finally {
2323
+ deadline.cancel();
2090
2324
  this.#removing.delete(name);
2091
2325
  }
2092
2326
  }
2093
2327
  /**
2094
2328
  * Отключает все плагины окончательно.
2095
2329
  *
2096
- * Очистка идёт изнутри наружу — в порядке, обратном выполнению расширений.
2330
+ * Очистка идёт изнутри наружу — в порядке, обратном выполнению расширений. Срок ожидания
2331
+ * общий на все плагины.
2097
2332
  */
2098
2333
  async dispose() {
2099
2334
  const entries = [...this.#ordered].reverse();
@@ -2101,13 +2336,14 @@ var PluginRegistry = class {
2101
2336
  this.#entries.clear();
2102
2337
  this.#ordered = [];
2103
2338
  for (const { plugin } of entries) this.#removing.add(plugin.name);
2104
- await this.#trackCleanup((async () => {
2339
+ const deadline = createDeadline(this.#options.shutdownTimeout, this.#options.clock);
2340
+ const cleanup = this.#trackCleanup((async () => {
2105
2341
  const errors = [];
2106
2342
  const previous = await Promise.allSettled(previousCleanups);
2107
2343
  for (const result of previous) if (result.status === "rejected") errors.push(result.reason);
2108
2344
  for (const entry of entries) try {
2109
- await this.#waitForDrain(entry);
2110
- await entry.teardown?.();
2345
+ const expired = await this.#release(entry, deadline);
2346
+ if (expired) errors.push(expired);
2111
2347
  } catch (error) {
2112
2348
  errors.push(error);
2113
2349
  } finally {
@@ -2115,6 +2351,11 @@ var PluginRegistry = class {
2115
2351
  }
2116
2352
  if (errors.length > 0) throw new AggregateError(errors, "Не удалось освободить ресурсы плагинов");
2117
2353
  })());
2354
+ try {
2355
+ await cleanup;
2356
+ } finally {
2357
+ deadline.cancel();
2358
+ }
2118
2359
  }
2119
2360
  /**
2120
2361
  * Прогоняет запрос через operation transformers и прикрепляет attempt interceptors.
@@ -2122,6 +2363,9 @@ var PluginRegistry = class {
2122
2363
  * Снимки обеих цепочек берутся в начале: `unuse()` влияет на новые операции, но не меняет
2123
2364
  * уже выполняющуюся и не удаляет interceptors из её последующих retry.
2124
2365
  *
2366
+ * Каждый `next` одноразовый: transformer может завершить операцию сам, но не может породить
2367
+ * вторую — для `posts.create` это была бы вторая публикация.
2368
+ *
2125
2369
  * @param execute выполнение логической операции, вызываемое самым внутренним transformer
2126
2370
  */
2127
2371
  async run(request, execute) {
@@ -2132,7 +2376,17 @@ var PluginRegistry = class {
2132
2376
  interceptor
2133
2377
  })));
2134
2378
  const scoped = (current) => withAttemptInterceptorScope(current, interceptorScope);
2135
- const chain = entries.flatMap((entry) => entry.transformers).reduceRight((next, transformer) => (current) => transformer(scoped(current), (prepared) => next(scoped(prepared))), (current) => execute(scoped(current)));
2379
+ const chain = entries.flatMap((entry) => entry.transformers.map((transformer) => ({
2380
+ plugin: entry.plugin.name,
2381
+ transformer
2382
+ }))).reduceRight((next, { plugin, transformer }) => (current) => {
2383
+ let called = false;
2384
+ return transformer(scoped(current), (prepared) => {
2385
+ if (called) throw new ItdConfigError(`operation transformer плагина «${plugin}» вызвал next() больше одного раза`);
2386
+ called = true;
2387
+ return next(scoped(prepared));
2388
+ });
2389
+ }, (current) => execute(scoped(current)));
2136
2390
  try {
2137
2391
  return await chain(scoped(request));
2138
2392
  } finally {
@@ -2146,6 +2400,18 @@ var PluginRegistry = class {
2146
2400
  }
2147
2401
  }
2148
2402
  }
2403
+ /**
2404
+ * Дожидается операций плагина и освобождает его ресурсы.
2405
+ *
2406
+ * `teardown` выполняется в любом случае, в том числе после истечения срока.
2407
+ *
2408
+ * @returns ошибка истёкшего срока, если ждать пришлось дольше отведённого
2409
+ */
2410
+ async #release(entry, deadline) {
2411
+ const finished = await deadline.wait(this.#waitForDrain(entry));
2412
+ await entry.teardown?.();
2413
+ return finished ? void 0 : new ItdStateError(`плагин «${entry.plugin.name}» не завершил операции за ${this.#options.shutdownTimeout} мс; ожидание прекращено, ресурсы плагина освобождены`);
2414
+ }
2149
2415
  #waitForDrain(entry) {
2150
2416
  if (entry.activeRequests === 0) return Promise.resolve();
2151
2417
  entry.drain ??= new Promise((resolve) => {
@@ -2165,6 +2431,10 @@ var PluginRegistry = class {
2165
2431
  function queueAbortError() {
2166
2432
  return new ItdAbortError("Запрос отменён во время ожидания очереди");
2167
2433
  }
2434
+ /** Ошибка запроса, которого застала остановка очереди. */
2435
+ function queueStoppedError() {
2436
+ return new ItdAbortError("Клиент закрыт, запрос отменён");
2437
+ }
2168
2438
  /**
2169
2439
  * Очередь запросов: ограничивает одновременность и частоту.
2170
2440
  *
@@ -2180,6 +2450,7 @@ var RequestQueue = class {
2180
2450
  #concurrency;
2181
2451
  /** Минимальный промежуток между стартами, мс. `0` — без ограничения частоты. */
2182
2452
  #minGap;
2453
+ #onDispatch;
2183
2454
  #waiting = [];
2184
2455
  #active = 0;
2185
2456
  /** Момент, раньше которого следующий запрос стартовать не должен. */
@@ -2189,6 +2460,7 @@ var RequestQueue = class {
2189
2460
  constructor(options, clock = systemClock) {
2190
2461
  this.#concurrency = options.concurrency;
2191
2462
  this.#minGap = options.rps ? 1e3 / options.rps : 0;
2463
+ this.#onDispatch = options.onDispatch;
2192
2464
  this.#clock = clock;
2193
2465
  }
2194
2466
  /** Сколько задач выполняется прямо сейчас. */
@@ -2220,6 +2492,7 @@ var RequestQueue = class {
2220
2492
  run: () => {
2221
2493
  detach();
2222
2494
  this.#active += 1;
2495
+ this.#onDispatch?.();
2223
2496
  Promise.resolve().then(task).then(resolve, reject).finally(() => {
2224
2497
  this.#active -= 1;
2225
2498
  this.#drain();
@@ -2245,16 +2518,10 @@ var RequestQueue = class {
2245
2518
  this.#cancelTimer();
2246
2519
  this.#cancelTimer = void 0;
2247
2520
  }
2248
- this.#nextSlot = 0;
2249
2521
  const pending = this.#waiting.splice(0, this.#waiting.length);
2250
- for (const task of pending) task.cancel(new ItdAbortError("Клиент закрыт, запрос отменён"));
2522
+ for (const task of pending) task.cancel(queueStoppedError());
2251
2523
  }
2252
- /**
2253
- * Придерживает всю очередь на заданное время.
2254
- *
2255
- * Вызывается при получении `429` с заголовком `Retry-After`: тормозить нужно все запросы,
2256
- * а не только тот, который наткнулся на лимит, — иначе остальные продолжат добивать API.
2257
- */
2524
+ /** Придерживает очередь на заданное время: ждут все её задачи, а не только одна. */
2258
2525
  pause(ms) {
2259
2526
  if (ms <= 0) return;
2260
2527
  this.#nextSlot = Math.max(this.#nextSlot, this.#clock.now() + ms);
@@ -2285,39 +2552,241 @@ var RequestQueue = class {
2285
2552
  this.#drain();
2286
2553
  }
2287
2554
  };
2555
+ /** Длина окна лимита на сервере. */
2556
+ const RATE_LIMIT_WINDOW = 6e4;
2557
+ /**
2558
+ * Пауза при исчерпании бакета неизвестной ёмкости.
2559
+ *
2560
+ * Ёмкость приходит в заголовке вместе с остатком, поэтому случай возможен только у чужого
2561
+ * прокси, который прислал `remaining` без `limit`.
2562
+ */
2563
+ const UNKNOWN_CAPACITY_PAUSE = 1e3;
2564
+ /**
2565
+ * Очередь одного бакета поверх общей очереди направления.
2566
+ *
2567
+ * Задача занимает слот бакета, затем общий слот направления, поэтому суммарная
2568
+ * одновременность остаётся равной `concurrency`. Пауза бакета удерживает задачу до
2569
+ * захвата общего слота: притормозивший счётчик не занимает общую ёмкость.
2570
+ *
2571
+ * @internal
2572
+ */
2573
+ var BucketQueue = class {
2574
+ #destination;
2575
+ #bucket;
2576
+ #gate;
2577
+ #shared;
2578
+ #clock;
2579
+ #pacing;
2580
+ /** Ровный темп. Требует раздельных бакетов: без них ёмкость счётчика неизвестна. */
2581
+ #smooth;
2582
+ /**
2583
+ * Пауза на исчерпанный остаток в режиме `buckets: false`; `undefined` — бакеты разделены.
2584
+ *
2585
+ * Одна очередь на направление принимает заголовки всех счётчиков вперемешку, поэтому
2586
+ * `x-ratelimit-limit` принадлежит тому счётчику, который ответил последним, и ёмкость
2587
+ * очереди из него не выводится: ответ `posts.create` с ёмкостью 5 остановил бы всё
2588
+ * направление на двенадцать секунд. Вместо расчёта берётся первая ступень `retryDelays`.
2589
+ */
2590
+ #flatPause;
2591
+ /** Лимит бакета до первого ответа. */
2592
+ #seedLimit;
2593
+ /** Последнее, что сказал сервер. Живёт и в режиме `off` — ради `rateLimitState()`. */
2594
+ #limit;
2595
+ #remaining;
2596
+ /**
2597
+ * Оценка остатка для режима `smooth`.
2598
+ *
2599
+ * Начинается с единицы, а не с полной ёмкости: где сейчас граница минутного окна,
2600
+ * из ответа не вывести, и считать бакет нетронутым нельзя.
2601
+ */
2602
+ #tokens = 1;
2603
+ #tokensAt;
2604
+ /**
2605
+ * Номер поколения очереди. `stop()` увеличивает его, отсекая задачи, которые уже взяли
2606
+ * слот бакета, но до общей очереди ещё не дошли.
2607
+ */
2608
+ #generation = 0;
2609
+ constructor(destination, bucket, shared, options, clock) {
2610
+ this.#destination = destination;
2611
+ this.#bucket = bucket;
2612
+ this.#shared = shared;
2613
+ this.#clock = clock;
2614
+ this.#pacing = options.pacing;
2615
+ this.#smooth = options.buckets && options.pacing === RateLimitPacing.Smooth;
2616
+ this.#flatPause = options.buckets ? void 0 : options.retryDelays[0] ?? 0;
2617
+ this.#seedLimit = options.buckets ? seedLimit(bucket, options) : void 0;
2618
+ this.#gate = new RequestQueue({
2619
+ concurrency: options.buckets ? options.bucketOverrides[bucket]?.concurrency ?? options.bucketConcurrency : options.concurrency,
2620
+ onDispatch: this.#smooth ? () => this.#spend() : void 0
2621
+ }, clock);
2622
+ }
2623
+ /** Имя счётчика. При `buckets: false` — всегда `default`, каким бы ни был запрос. */
2624
+ get bucket() {
2625
+ return this.#bucket;
2626
+ }
2627
+ /** Запросов бакета прошло в общую очередь и ещё не завершилось. */
2628
+ get active() {
2629
+ return this.#gate.active;
2630
+ }
2631
+ /** Запросов бакета ждёт своей очереди. */
2632
+ get pending() {
2633
+ return this.#gate.pending;
2634
+ }
2635
+ /** Ставит запрос в очередь: сначала слот бакета, затем общий слот направления. */
2636
+ schedule(task, signal) {
2637
+ const generation = this.#generation;
2638
+ return this.#gate.schedule(() => {
2639
+ if (generation !== this.#generation) return Promise.reject(queueStoppedError());
2640
+ return this.#shared.schedule(task, signal);
2641
+ }, signal);
2642
+ }
2643
+ /**
2644
+ * Учитывает заголовки ответа.
2645
+ *
2646
+ * Вызывается после каждого ответа, включая ошибочные: сервер списывает квоту одинаково
2647
+ * с `404`, `422` и `200`.
2648
+ *
2649
+ * @returns на сколько миллисекунд придержан бакет; `0` — темп не ограничен
2650
+ */
2651
+ observe(limit, remaining) {
2652
+ if (limit !== void 0 && Number.isFinite(limit) && limit > 0) this.#limit = limit;
2653
+ if (remaining !== void 0) this.#remaining = remaining;
2654
+ if (this.#pacing === RateLimitPacing.Off || remaining === void 0) return 0;
2655
+ if (this.#flatPause !== void 0) {
2656
+ if (remaining > 0) return 0;
2657
+ this.#gate.pause(this.#flatPause);
2658
+ return this.#flatPause;
2659
+ }
2660
+ const capacity = this.#capacity();
2661
+ if (this.#smooth) {
2662
+ if (capacity === void 0) return 0;
2663
+ this.#refill(capacity);
2664
+ if (remaining < this.#tokens) this.#tokens = remaining;
2665
+ return this.#armPause(capacity);
2666
+ }
2667
+ if (remaining > 0) return 0;
2668
+ const wait = capacity === void 0 ? UNKNOWN_CAPACITY_PAUSE : Math.ceil(RATE_LIMIT_WINDOW / Math.max(capacity, 1));
2669
+ this.#gate.pause(wait);
2670
+ return wait;
2671
+ }
2672
+ /** Придерживает бакет на названное время — путь ответа `429`. Оценка остатка обнуляется. */
2673
+ pause(ms) {
2674
+ if (this.#smooth) {
2675
+ this.#tokens = 0;
2676
+ this.#tokensAt = this.#clock.now();
2677
+ }
2678
+ this.#gate.pause(ms);
2679
+ }
2680
+ /** Снимок для `rateLimitState()`. */
2681
+ state() {
2682
+ return {
2683
+ destination: this.#destination,
2684
+ bucket: this.#bucket,
2685
+ limit: this.#limit,
2686
+ remaining: this.#remaining,
2687
+ active: this.#gate.active,
2688
+ pending: this.#gate.pending
2689
+ };
2690
+ }
2691
+ /**
2692
+ * Останавливает уровень бакета. Общая очередь направления гасится пулом.
2693
+ *
2694
+ */
2695
+ stop() {
2696
+ this.#generation += 1;
2697
+ this.#gate.stop();
2698
+ }
2699
+ /** Лимит бакета: сказанный сервером, иначе табличный. */
2700
+ #capacity() {
2701
+ return this.#limit ?? this.#seedLimit;
2702
+ }
2703
+ /** Списывает токен на уходящий запрос и придерживает бакет до следующего. */
2704
+ #spend() {
2705
+ const capacity = this.#capacity();
2706
+ if (capacity === void 0) return;
2707
+ this.#refill(capacity);
2708
+ this.#tokens -= 1;
2709
+ this.#armPause(capacity);
2710
+ }
2711
+ /** Возвращает накопленное с прошлой проверки: `limit` единиц за минуту. */
2712
+ #refill(capacity) {
2713
+ const now = this.#clock.now();
2714
+ if (this.#tokensAt !== void 0) {
2715
+ const restored = (now - this.#tokensAt) * capacity / RATE_LIMIT_WINDOW;
2716
+ this.#tokens = Math.min(capacity, this.#tokens + restored);
2717
+ }
2718
+ this.#tokensAt = now;
2719
+ }
2720
+ /** Держит бакет, пока не накопится хотя бы один токен. */
2721
+ #armPause(capacity) {
2722
+ if (this.#tokens >= 1) return 0;
2723
+ const wait = Math.ceil((1 - this.#tokens) * RATE_LIMIT_WINDOW / capacity);
2724
+ this.#gate.pause(wait);
2725
+ return wait;
2726
+ }
2727
+ };
2728
+ /** Лимит бакета до первого ответа: поправка пользователя важнее табличного значения. */
2729
+ function seedLimit(bucket, options) {
2730
+ const override = options.bucketOverrides[bucket]?.limit;
2731
+ if (override !== void 0) return override;
2732
+ return isKnownBucket(bucket) ? BUCKET_LIMITS[bucket] : void 0;
2733
+ }
2288
2734
  /**
2289
- * Очереди по конечным направлениям запросов.
2735
+ * Очереди по парам «направление — серверный счётчик частоты».
2290
2736
  *
2291
- * Ключом обычно служит origin уже разрешённого URL. Поэтому разные локальные имена одного
2292
- * хоста разделяют лимит, а запрос с разовым внешним `baseUrl` не попадает в основную очередь.
2737
+ * Направление origin уже разрешённого URL: разные локальные имена одного хоста делят
2738
+ * лимит, а запрос с разовым внешним `baseUrl` не попадает в основную очередь. Мощность
2739
+ * карты ограничена каталогом операций, поэтому ни TTL, ни вытеснение не нужны.
2293
2740
  *
2294
2741
  * @internal
2295
2742
  */
2296
2743
  var RequestQueuePool = class {
2297
2744
  #options;
2298
2745
  #clock;
2299
- #main;
2300
- /** Очереди направлений заводятся при первом запросе. */
2301
- #byDestination = /* @__PURE__ */ new Map();
2746
+ /** Ключ `undefined` — основная очередь внутренних клиентов без известного направления. */
2747
+ #destinations = /* @__PURE__ */ new Map();
2302
2748
  constructor(options, clock = systemClock) {
2303
2749
  this.#options = options;
2304
2750
  this.#clock = clock;
2305
- this.#main = new RequestQueue(options, clock);
2306
2751
  }
2307
- /** Очередь направления. `undefined` сохраняет основную очередь для внутренних клиентов. */
2308
- for(destination) {
2309
- if (destination === void 0) return this.#main;
2310
- let queue = this.#byDestination.get(destination);
2752
+ /** Очередь бакета на направлении. При `buckets: false` бакет всегда `default`. */
2753
+ for(destination, bucket = DEFAULT_RATE_LIMIT_BUCKET) {
2754
+ const name = this.#options.buckets ? bucket : DEFAULT_RATE_LIMIT_BUCKET;
2755
+ let entry = this.#destinations.get(destination);
2756
+ if (!entry) {
2757
+ entry = {
2758
+ shared: new RequestQueue(this.#options, this.#clock),
2759
+ buckets: /* @__PURE__ */ new Map()
2760
+ };
2761
+ this.#destinations.set(destination, entry);
2762
+ }
2763
+ let queue = entry.buckets.get(name);
2311
2764
  if (!queue) {
2312
- queue = new RequestQueue(this.#options, this.#clock);
2313
- this.#byDestination.set(destination, queue);
2765
+ queue = new BucketQueue(destination, name, entry.shared, this.#options, this.#clock);
2766
+ entry.buckets.set(name, queue);
2314
2767
  }
2315
2768
  return queue;
2316
2769
  }
2317
- /** Останавливает все очереди. */
2770
+ /** Снимки всех бакетов, о которых что-то известно. */
2771
+ states() {
2772
+ const states = [];
2773
+ for (const entry of this.#destinations.values()) for (const queue of entry.buckets.values()) states.push(queue.state());
2774
+ return states;
2775
+ }
2776
+ /**
2777
+ * Останавливает оба уровня всех очередей: ожидающие задачи отклоняются, а состояние
2778
+ * счётчиков и отложенные паузы сохраняются до следующего запуска — путь `close()`.
2779
+ */
2318
2780
  stop() {
2319
- this.#main.stop();
2320
- for (const queue of this.#byDestination.values()) queue.stop();
2781
+ for (const entry of this.#destinations.values()) {
2782
+ for (const queue of entry.buckets.values()) queue.stop();
2783
+ entry.shared.stop();
2784
+ }
2785
+ }
2786
+ /** Останавливает очереди и забывает всё, что известно о счётчиках, — путь `dispose()`. */
2787
+ clear() {
2788
+ this.stop();
2789
+ this.#destinations.clear();
2321
2790
  }
2322
2791
  };
2323
2792
  //#endregion
@@ -2685,6 +3154,11 @@ function readIntHeader(headers, name) {
2685
3154
  const value = Number.parseInt(raw, 10);
2686
3155
  return Number.isFinite(value) ? value : void 0;
2687
3156
  }
3157
+ /** Читает положительное целое число из заголовка. */
3158
+ function readPositiveIntHeader(headers, name) {
3159
+ const value = readIntHeader(headers, name);
3160
+ return value !== void 0 && value > 0 ? value : void 0;
3161
+ }
2688
3162
  /**
2689
3163
  * Читает сведения об ограничении частоты.
2690
3164
  *
@@ -2693,7 +3167,7 @@ function readIntHeader(headers, name) {
2693
3167
  */
2694
3168
  function readRateLimit(headers) {
2695
3169
  return {
2696
- limit: readIntHeader(headers, "x-ratelimit-limit"),
3170
+ limit: readPositiveIntHeader(headers, "x-ratelimit-limit"),
2697
3171
  remaining: readIntHeader(headers, "x-ratelimit-remaining")
2698
3172
  };
2699
3173
  }
@@ -2842,17 +3316,25 @@ function abortable(promise, signal) {
2842
3316
  });
2843
3317
  }
2844
3318
  /**
2845
- * Объединяет пользовательский `AbortSignal` с таймаутом.
3319
+ * Объединяет пользовательский `AbortSignal`, сигнал жизни клиента и таймаут.
2846
3320
  *
2847
3321
  * Реализовано вручную, а не через `AbortSignal.any`: последний появился только в Node 20,
2848
- * а библиотека поддерживает Node 18.
3322
+ * а библиотека поддерживает Node 18. Причина отмены переносится от источника как есть.
2849
3323
  */
2850
- function createAbortBundle(userSignal, timeout, clock) {
3324
+ function createAbortBundle(userSignal, lifetimeSignal, timeout, clock) {
2851
3325
  const controller = new AbortController();
2852
3326
  let timedOut = false;
2853
- const onUserAbort = () => controller.abort(userSignal?.reason);
2854
- if (userSignal) if (userSignal.aborted) controller.abort(userSignal.reason);
2855
- else userSignal.addEventListener("abort", onUserAbort, { once: true });
3327
+ const link = (source) => {
3328
+ if (!source) return void 0;
3329
+ if (source.aborted) {
3330
+ controller.abort(source.reason);
3331
+ return;
3332
+ }
3333
+ const onAbort = () => controller.abort(source.reason);
3334
+ source.addEventListener("abort", onAbort, { once: true });
3335
+ return () => source.removeEventListener("abort", onAbort);
3336
+ };
3337
+ const unlink = [link(userSignal), link(lifetimeSignal)];
2856
3338
  const cancelTimer = timeout > 0 ? clock.schedule(() => {
2857
3339
  timedOut = true;
2858
3340
  controller.abort();
@@ -2862,7 +3344,7 @@ function createAbortBundle(userSignal, timeout, clock) {
2862
3344
  timedOut: () => timedOut,
2863
3345
  cleanup: () => {
2864
3346
  cancelTimer?.();
2865
- userSignal?.removeEventListener("abort", onUserAbort);
3347
+ for (const detach of unlink) detach?.();
2866
3348
  }
2867
3349
  };
2868
3350
  }
@@ -2899,7 +3381,7 @@ var Transport = class {
2899
3381
  const headers = await this.#buildHeaders(request, url);
2900
3382
  const attempt = request.attempt ?? 1;
2901
3383
  const timeout = request.timeout ?? this.#config.timeout;
2902
- const abort = createAbortBundle(request.signal, timeout, this.#config.clock);
3384
+ const abort = createAbortBundle(request.signal, this.#deps.lifetimeSignal, timeout, this.#config.clock);
2903
3385
  const startedAt = this.#config.clock.now();
2904
3386
  let cleanupBody;
2905
3387
  try {
@@ -3116,7 +3598,7 @@ var Transport = class {
3116
3598
  path: request.path
3117
3599
  });
3118
3600
  if (aborted) {
3119
- const reason = request.signal?.reason;
3601
+ const reason = abort.signal.reason;
3120
3602
  return new ItdAbortError(`Запрос ${method} ${request.path} отменён`, reason !== void 0 ? { cause: reason } : void 0);
3121
3603
  }
3122
3604
  if (error instanceof ItdError) return error;
@@ -3157,38 +3639,55 @@ function createServiceRegistry(config) {
3157
3639
  function createClientRuntime(options = {}, internals = {}) {
3158
3640
  const config = resolveConfig(options);
3159
3641
  const jar = new CookieJar();
3160
- const plugins = new PluginRegistry();
3642
+ const plugins = new PluginRegistry({
3643
+ shutdownTimeout: config.shutdownTimeout,
3644
+ clock: config.clock
3645
+ });
3161
3646
  const services = createServiceRegistry(config);
3647
+ const lifetime = new AbortController();
3162
3648
  const sharedQueues = config.rateLimit ? internals.queues : void 0;
3163
3649
  const queues = sharedQueues ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit, config.clock) : void 0);
3164
3650
  const ownsQueues = sharedQueues === void 0;
3165
3651
  let auth;
3166
3652
  let transport;
3653
+ /**
3654
+ * Бакет, из которого спишется запрос.
3655
+ *
3656
+ * Источники по убыванию приоритета: `rateLimitBucket` запроса, правило `rateLimit.bucket`,
3657
+ * каталог операций.
3658
+ */
3659
+ const bucketFor = (request) => {
3660
+ if (request.rateLimitBucket !== void 0) return request.rateLimitBucket;
3661
+ return config.rateLimit?.bucket?.({
3662
+ operationId: request.operationId,
3663
+ method: request.method,
3664
+ path: request.path
3665
+ }) ?? operationBucket(request.operationId);
3666
+ };
3667
+ const queueKeyFor = (request) => requestQueueKey(request, (target) => ({
3668
+ destination: originOf(transport.buildUrl(target)) || void 0,
3669
+ bucket: bucketFor(target)
3670
+ }));
3167
3671
  const queueFor = (request) => {
3168
- const destination = originOf(transport.buildUrl(request));
3169
- return queues?.for(destination || void 0);
3672
+ if (!queues) return void 0;
3673
+ const key = queueKeyFor(request);
3674
+ return queues.for(key.destination, key.bucket);
3170
3675
  };
3171
- /**
3172
- * Сервер сообщает остаток окна в `x-ratelimit-remaining`, но не сообщает момент сброса.
3173
- * При нуле заранее ставим очередь конечного origin на первую, самую короткую паузу из
3174
- * `retryDelays`: окно могло почти истечь, поэтому длинный backoff здесь преждевременен.
3175
- * Если окно всё ещё закрыто, следующий `429` применит самостоятельную лестницу повторов.
3176
- * Общая пауза очереди не даёт параллельным запросам одновременно ударить в тот же лимит.
3177
- */
3178
- const throttleByHeaders = (limit, remaining, request) => {
3179
- if (remaining === void 0 || remaining > 0) return;
3180
- const first = config.rateLimit?.retryDelays[0];
3181
- if (first === void 0) return;
3182
- queueFor(request)?.pause(first);
3183
- config.logger?.debug(`лимит сервера исчерпан (${remaining} из ${limit ?? "?"}), очередь ждёт ${first} мс`);
3676
+ /** Передаёт остаток из заголовков ответа бакету запроса; тот решает, тормозить ли себя. */
3677
+ const observeRateLimit = (limit, remaining, request) => {
3678
+ const queue = queueFor(request);
3679
+ if (!queue) return;
3680
+ const waited = queue.observe(limit, remaining);
3681
+ if (waited > 0) config.logger?.debug(`остаток лимита ${remaining} из ${limit ?? "?"}, бакет ${queue.bucket} ждёт ${waited} мс`);
3184
3682
  };
3185
3683
  transport = new Transport({
3186
3684
  ...config,
3187
3685
  hooks: config.hooks
3188
3686
  }, {
3687
+ onRateLimit: queues ? observeRateLimit : void 0,
3189
3688
  cookies: config.useCookieJar ? jar : void 0,
3190
3689
  getDeviceId: () => auth.getDeviceId(),
3191
- onRateLimit: queues && config.rateLimit?.respectHeaders ? throttleByHeaders : void 0
3690
+ lifetimeSignal: lifetime.signal
3192
3691
  });
3193
3692
  const stages = [
3194
3693
  {
@@ -3242,6 +3741,7 @@ function createClientRuntime(options = {}, internals = {}) {
3242
3741
  const clientHandler = (request) => {
3243
3742
  try {
3244
3743
  if (!isDisposeCleanupRequest(request)) internals.assertActive?.("выполнить новый запрос");
3744
+ if (request.rateLimitBucket !== void 0) assertKnownBucket(request.rateLimitBucket, "rateLimitBucket", config.rateLimit?.bucket);
3245
3745
  } catch (error) {
3246
3746
  return Promise.reject(error);
3247
3747
  }
@@ -3262,12 +3762,15 @@ function createClientRuntime(options = {}, internals = {}) {
3262
3762
  services,
3263
3763
  stageOrder,
3264
3764
  platformHeaders: (url) => transport.platformHeaders(url),
3765
+ rateLimitState: () => queues?.states() ?? [],
3265
3766
  close: () => {
3266
3767
  if (ownsQueues) queues?.stop();
3267
3768
  },
3268
- dispose: () => {
3769
+ dispose: async () => {
3770
+ lifetime.abort(new ItdAbortError("Клиент освобождён через dispose(), запрос отменён"));
3771
+ if (ownsQueues) queues?.clear();
3269
3772
  auth.dispose();
3270
- return plugins.dispose();
3773
+ await plugins.dispose();
3271
3774
  }
3272
3775
  };
3273
3776
  }
@@ -3625,7 +4128,12 @@ var RealtimeDispatcher = class {
3625
4128
  if (index >= 0) this.#handlers.splice(index, 1);
3626
4129
  };
3627
4130
  }
3628
- dispatch(context) {
4131
+ /**
4132
+ * Принимает обновление к обработке.
4133
+ *
4134
+ * @param coalesceKey ожидающая работа с тем же ключом заменяется новой
4135
+ */
4136
+ dispatch(context, coalesceKey) {
3629
4137
  let keys;
3630
4138
  let middleware;
3631
4139
  try {
@@ -3635,12 +4143,20 @@ var RealtimeDispatcher = class {
3635
4143
  this.#hooks.middlewareError(error, context);
3636
4144
  return;
3637
4145
  }
3638
- this.#queue.push({
4146
+ const replaced = coalesceKey === void 0 ? -1 : this.#queue.findIndex((pending) => pending.coalesceKey === coalesceKey);
4147
+ if (replaced < 0 && this.#queue.length >= 256) {
4148
+ this.#hooks.overflow();
4149
+ return;
4150
+ }
4151
+ const work = {
3639
4152
  context,
3640
4153
  middleware,
3641
4154
  handlers: [...this.#handlers],
3642
- keys
3643
- });
4155
+ keys,
4156
+ coalesceKey
4157
+ };
4158
+ if (replaced >= 0) this.#queue[replaced] = work;
4159
+ else this.#queue.push(work);
3644
4160
  this.#pump();
3645
4161
  }
3646
4162
  /** Отбрасывает обновления, обработка которых ещё не началась. */
@@ -3717,14 +4233,6 @@ const RECONNECT_BACKOFF = Object.freeze([
3717
4233
  16e3,
3718
4234
  3e4
3719
4235
  ]);
3720
- /** Доля случайного разброса паузы. */
3721
- const RECONNECT_JITTER = .3;
3722
- /**
3723
- * Сколько раз пытаться переподключиться подряд.
3724
- *
3725
- * После исчерпания поток сообщает `giveup` и ждёт ручного `connect()`.
3726
- */
3727
- const MAX_RECONNECT_ATTEMPTS = 15;
3728
4236
  /**
3729
4237
  * Вычисляет паузу перед попыткой переподключения.
3730
4238
  *
@@ -4025,7 +4533,8 @@ var RealtimeEngine = class {
4025
4533
  }, {
4026
4534
  deliver: (context) => this.#deps.deliver(context.update),
4027
4535
  middlewareError: (error, context) => this.#reportDispatchError("middlewareError", error, context),
4028
- handlerError: (error, context) => this.#reportDispatchError("handlerError", error, context)
4536
+ handlerError: (error, context) => this.#reportDispatchError("handlerError", error, context),
4537
+ overflow: () => this.#handleOverflow()
4029
4538
  });
4030
4539
  }
4031
4540
  /** Текущее состояние соединения. */
@@ -4106,7 +4615,23 @@ var RealtimeEngine = class {
4106
4615
  /** Пропускает актуальное обновление через цепочку обработчиков. */
4107
4616
  #dispatch(update, raw, origin) {
4108
4617
  if (!this.#wanted) return;
4109
- this.#dispatcher.dispatch(this.#deps.createContext(update, raw, origin));
4618
+ this.#dispatcher.dispatch(this.#deps.createContext(update, raw, origin), this.#deps.coalesceKey?.(update));
4619
+ }
4620
+ /**
4621
+ * Очередь обновлений достигла предела: закрывает соединение и переподключается,
4622
+ * когда обработчики разберут очередь.
4623
+ */
4624
+ #handleOverflow() {
4625
+ const controller = this.#controller;
4626
+ if (!this.#wanted || !controller) return;
4627
+ const generation = this.#generation;
4628
+ controller.abort();
4629
+ this.#controller = void 0;
4630
+ this.#setStatus(RealtimeStatus.Error);
4631
+ const error = /* @__PURE__ */ new Error("Обработчики не успевают за потоком: очередь обновлений переполнена");
4632
+ this.#dispatcher.drain().then(() => {
4633
+ if (this.#isCurrentGeneration(generation) && !this.#controller) this.#scheduleReconnect(error);
4634
+ });
4110
4635
  }
4111
4636
  /** Выполняет доменную синхронизацию, не роняя подключение из-за её ошибки. */
4112
4637
  async #sync(reason, generation, starting) {
@@ -4131,6 +4656,7 @@ var RealtimeEngine = class {
4131
4656
  baseUrl: this.#deps.baseUrl,
4132
4657
  authorize: this.#deps.authorize,
4133
4658
  fetch: this.#deps.fetch,
4659
+ request: this.#deps.request,
4134
4660
  baseHeaders: this.#deps.baseHeaders,
4135
4661
  getToken: this.#deps.getToken,
4136
4662
  signal: controller.signal,
@@ -4229,7 +4755,12 @@ var RealtimeEngine = class {
4229
4755
  this.#starting = void 0;
4230
4756
  if (this.#isCurrentGeneration(generation)) this.#run(generation);
4231
4757
  }
4232
- /** Завершает автоматические попытки переподключения. */
4758
+ /**
4759
+ * Завершает автоматические попытки переподключения.
4760
+ *
4761
+ * Владелец узнаёт об этом так же, как при {@link disconnect}. `onClose` вызывается
4762
+ * до событий: обработчик `giveup` может тут же вызвать `connect()`.
4763
+ */
4233
4764
  #giveUp(error) {
4234
4765
  this.#wanted = false;
4235
4766
  this.#generation += 1;
@@ -4237,6 +4768,7 @@ var RealtimeEngine = class {
4237
4768
  this.#attempt = 0;
4238
4769
  this.#detachEnvironment?.();
4239
4770
  this.#detachEnvironment = void 0;
4771
+ this.#deps.onClose?.();
4240
4772
  this.#emitEngine("error", {
4241
4773
  error,
4242
4774
  willReconnect: false
@@ -4309,26 +4841,15 @@ var PollTransport = class {
4309
4841
  this.#limit = options.limit ?? 20;
4310
4842
  }
4311
4843
  async connect(context) {
4844
+ const request = context.request;
4845
+ if (!request) throw new ItdConfigError("опрос уведомлений выполняется через конвейер клиента; создайте поток вызовом itd.realtime()");
4312
4846
  const seen = /* @__PURE__ */ new Set();
4313
4847
  let firstRun = true;
4314
4848
  let lastUnreadCount;
4315
4849
  while (!context.signal.aborted) {
4316
- const token = context.authorize ? await context.getToken() : null;
4317
- if (context.authorize && !token) throw new UnauthorizedStreamError();
4318
- const url = `${joinUrl(context.baseUrl, "/api/notifications/")}?limit=${this.#limit}&offset=0`;
4319
- const headers = await context.baseHeaders(url);
4320
- headers.set("Accept", "application/json");
4321
- if (token) headers.set("Authorization", `Bearer ${token}`);
4322
- const response = await context.fetch(url, {
4323
- method: "GET",
4324
- headers,
4325
- signal: context.signal
4326
- });
4327
- if (context.authorize && response.status === 401) throw new UnauthorizedStreamError();
4328
- if (!response.ok) throw new Error(`Опрос уведомлений вернул статус ${response.status}`);
4850
+ const payload = await this.#readUpdates(request, context.signal);
4329
4851
  context.onOpen();
4330
- const body = await response.json();
4331
- const items = pickArray(typeof body === "object" && body !== null && "data" in body ? body.data : body, "notifications");
4852
+ const items = pickArray(payload, "notifications");
4332
4853
  for (const item of [...items].reverse()) {
4333
4854
  const id = typeof item.id === "string" ? item.id : void 0;
4334
4855
  if (!id || seen.has(id)) continue;
@@ -4342,7 +4863,7 @@ var PollTransport = class {
4342
4863
  const excess = [...seen].slice(0, seen.size - this.#limit * 2);
4343
4864
  for (const id of excess) seen.delete(id);
4344
4865
  }
4345
- const count = await this.#readCount(context, headers);
4866
+ const count = await this.#readCount(request, context.signal);
4346
4867
  if (count !== void 0 && count !== lastUnreadCount) {
4347
4868
  lastUnreadCount = count;
4348
4869
  context.onEvent({
@@ -4354,16 +4875,29 @@ var PollTransport = class {
4354
4875
  await this.#wait(context.signal);
4355
4876
  }
4356
4877
  }
4357
- async #readCount(context, headers) {
4878
+ async #readUpdates(request, signal) {
4358
4879
  try {
4359
- const response = await context.fetch(joinUrl(context.baseUrl, "/api/notifications/count"), {
4360
- method: "GET",
4361
- headers,
4362
- signal: context.signal
4880
+ return await request({
4881
+ operationId: "realtime.poll.updates",
4882
+ path: "/api/notifications/",
4883
+ query: {
4884
+ limit: this.#limit,
4885
+ offset: 0
4886
+ },
4887
+ signal
4363
4888
  });
4364
- if (!response.ok) return void 0;
4365
- const body = await response.json();
4366
- return pickNumber(typeof body === "object" && body !== null && "data" in body ? body.data : body, "count", 0);
4889
+ } catch (error) {
4890
+ if (isItdAuthError(error)) throw new UnauthorizedStreamError();
4891
+ throw error;
4892
+ }
4893
+ }
4894
+ async #readCount(request, signal) {
4895
+ try {
4896
+ return pickNumber(await request({
4897
+ operationId: "realtime.poll.unread",
4898
+ path: "/api/notifications/count",
4899
+ signal
4900
+ }), "count", 0);
4367
4901
  } catch {
4368
4902
  return;
4369
4903
  }
@@ -4723,6 +5257,7 @@ var ItdRealtime = class {
4723
5257
  baseUrl: deps.baseUrl,
4724
5258
  authorize: deps.authorize ?? true,
4725
5259
  fetch: deps.fetch,
5260
+ request: deps.request,
4726
5261
  clock: deps.clock,
4727
5262
  baseHeaders: deps.baseHeaders,
4728
5263
  getToken: deps.getToken,
@@ -4733,6 +5268,7 @@ var ItdRealtime = class {
4733
5268
  transport: createTransport(deps, options),
4734
5269
  handleFrame: (event) => this.#handleFrame(event),
4735
5270
  readUpdate: readRealtimeUpdate,
5271
+ coalesceKey: (update) => update.type === RealtimeUpdateType.UnreadCount ? update.type : void 0,
4736
5272
  createContext: (update, raw, origin) => ({
4737
5273
  update,
4738
5274
  stream: this,
@@ -6009,10 +6545,6 @@ function createMultipartFileBody(file) {
6009
6545
  cancel
6010
6546
  };
6011
6547
  }
6012
- //#endregion
6013
- //#region src/resources/files.ts
6014
- /** Таймаут одной попытки загрузки файла — 5 минут. */
6015
- const DEFAULT_UPLOAD_TIMEOUT = 3e5;
6016
6548
  /** Файлы и медиа. */
6017
6549
  var FilesResource = class extends BaseResource {
6018
6550
  #fetch;
@@ -8959,6 +9491,22 @@ var ItdClient = class ItdClient {
8959
9491
  });
8960
9492
  }
8961
9493
  /**
9494
+ * Остаток серверных лимитов по бакетам, через которые уже проходили запросы.
9495
+ *
9496
+ * Значения берутся из последнего ответа каждого бакета и быстро устаревают: сервер
9497
+ * восстанавливает квоту линейно и границу окна не сообщает. Пустой массив при
9498
+ * `rateLimit: false`. {@link close} снимок сохраняет, {@link dispose} очищает.
9499
+ *
9500
+ * @example
9501
+ * ```ts
9502
+ * const posts = itd.rateLimitState().find((state) => state.bucket === 'posts.create');
9503
+ * if ((posts?.remaining ?? Number.POSITIVE_INFINITY) < 3) await sleep(60_000);
9504
+ * ```
9505
+ */
9506
+ rateLimitState() {
9507
+ return this.#runtime.rateLimitState();
9508
+ }
9509
+ /**
8962
9510
  * Подключает плагин.
8963
9511
  *
8964
9512
  * Плагин может независимо регистрировать transformer логической операции и interceptor
@@ -9094,6 +9642,11 @@ var ItdClient = class ItdClient {
9094
9642
  stream = new ItdRealtime({
9095
9643
  baseUrl: this.#runtime.config.baseUrl,
9096
9644
  fetch: this.#runtime.config.fetch,
9645
+ request: ({ operationId, path, query, signal }) => this.#runtime.http.operation(operationId, {
9646
+ path,
9647
+ query,
9648
+ signal
9649
+ }),
9097
9650
  baseHeaders: (url) => this.#runtime.platformHeaders(url),
9098
9651
  getAuthIdentity: () => this.#runtime.auth.getCurrentAuthIdentity(),
9099
9652
  getAuthScope: () => this.#runtime.auth.getAuthScope(),
@@ -9113,13 +9666,16 @@ var ItdClient = class ItdClient {
9113
9666
  * Освобождает ресурсы клиента: закрывает все потоки уведомлений, отправляет открытые
9114
9667
  * накопители {@link telemetry}, затем останавливает очередь запросов.
9115
9668
  *
9116
- * Метод дожидается активных обработчиков потока. После вызова клиентом можно пользоваться
9117
- * снова; ранее созданный поток можно запустить повторным `connect()`.
9669
+ * Метод дожидается активных обработчиков потока, но не дольше `shutdownTimeout`. После
9670
+ * вызова клиентом можно пользоваться снова; ранее созданный поток можно запустить
9671
+ * повторным `connect()`.
9118
9672
  *
9119
9673
  * Общая очередь, полученная от {@link ItdAccounts}, не останавливается: её гасит сам
9120
9674
  * контейнер, когда закрывает все аккаунты разом.
9121
9675
  *
9122
9676
  * Терминальное освобождение — это {@link dispose}.
9677
+ *
9678
+ * @throws {ItdStateError} если обработчики потока не завершились за отведённый срок
9123
9679
  */
9124
9680
  async close() {
9125
9681
  return this.#close(false);
@@ -9127,22 +9683,30 @@ var ItdClient = class ItdClient {
9127
9683
  async #close(disposeCleanup) {
9128
9684
  const streams = this.#disconnectStreams();
9129
9685
  if (disposeCleanup) prepareTelemetryForDispose(this.#telemetry);
9686
+ const { shutdownTimeout, clock } = this.#runtime.config;
9687
+ const deadline = createDeadline(shutdownTimeout, clock);
9688
+ let stuck = [];
9130
9689
  try {
9131
- await Promise.all(streams.map((stream) => stream.drain()));
9690
+ stuck = (await Promise.all(streams.map(async (stream) => await deadline.wait(stream.drain()) ? void 0 : stream))).filter((stream) => stream !== void 0);
9132
9691
  if (disposeCleanup) await closeTelemetryForDispose(this.#telemetry);
9133
9692
  else await this.#telemetry?.close();
9134
9693
  } finally {
9694
+ deadline.cancel();
9135
9695
  this.#runtime.close();
9136
9696
  }
9697
+ if (stuck.length > 0) throw new ItdStateError(`обработчики потоков (${stuck.map((stream) => stream.transport).join(", ")}) не завершились за ${shutdownTimeout} мс; ожидание прекращено`);
9137
9698
  }
9138
9699
  /**
9139
- * Окончательно освобождает клиент: выполняет {@link close} и отключает все плагины.
9700
+ * Окончательно освобождает клиент: выполняет {@link close}, отменяет незавершённые
9701
+ * запросы и отключает все плагины.
9140
9702
  *
9141
9703
  * Терминальное состояние устанавливается сразу при первом вызове. После этого новые
9142
9704
  * запросы, подключение плагинов, регистрация сервисов и создание или повторный запуск
9143
9705
  * realtime-потоков завершаются с {@link ItdStateError}. Повторные вызовы возвращают
9144
9706
  * тот же результат очистки.
9145
9707
  *
9708
+ * Ожидание обработчиков потока и операций плагинов ограничено `shutdownTimeout`.
9709
+ *
9146
9710
  * @example
9147
9711
  * ```ts
9148
9712
  * await using itd = new ItdClient({ auth: token });
@@ -9312,7 +9876,7 @@ var ItdAccounts = class ItdAccounts {
9312
9876
  this.#base = base;
9313
9877
  this.#storage = storage ?? new MemoryMultiTokenStorage();
9314
9878
  this.#plugins = orderPluginDefinitions(plugins ?? []);
9315
- this.#rateLimitScope = rateLimitScope ?? "account";
9879
+ this.#rateLimitScope = rateLimitScope ?? "shared";
9316
9880
  const rateLimit = this.#rateLimitScope === "shared" ? resolveRateLimit(base.rateLimit) : void 0;
9317
9881
  this.#queues = rateLimit ? new RequestQueuePool(rateLimit, base.clock ?? systemClock) : void 0;
9318
9882
  const logger = typeof base.logger === "object" ? base.logger : void 0;
@@ -9604,7 +10168,7 @@ var ItdAccounts = class ItdAccounts {
9604
10168
  ...clients.map((client) => client.dispose()),
9605
10169
  ...controls.map((control) => control.drain()),
9606
10170
  ...accountRemovals,
9607
- Promise.resolve().then(() => this.#queues?.stop())
10171
+ Promise.resolve().then(() => this.#queues?.clear())
9608
10172
  ]);
9609
10173
  this.#plugins.splice(0);
9610
10174
  this.#removingPlugins.clear();
@@ -10121,10 +10685,6 @@ var RealtimeComposer = class RealtimeComposer {
10121
10685
  return (context, next) => runRealtimeMiddleware(snapshot, context, next);
10122
10686
  }
10123
10687
  };
10124
- //#endregion
10125
- //#region src/realtime/websocket.ts
10126
- /** Стандартный путь WebSocket-подключения. */
10127
- const WEBSOCKET_PATH = "/api/ws";
10128
10688
  const CONNECTING = 0;
10129
10689
  const OPEN = 1;
10130
10690
  const NORMAL_CLOSURE = 1e3;
@@ -10262,6 +10822,7 @@ var WebSocketTransport = class {
10262
10822
  let discardMessages = false;
10263
10823
  let socketError;
10264
10824
  let messageQueue = Promise.resolve();
10825
+ let pendingFrames = 0;
10265
10826
  let cancelHandshake;
10266
10827
  let cancelIdle;
10267
10828
  let cancelKeepAlive;
@@ -10340,7 +10901,13 @@ var WebSocketTransport = class {
10340
10901
  pending = Promise.resolve({ error });
10341
10902
  }
10342
10903
  if (pending === void 0) return;
10904
+ if (pendingFrames >= 256) {
10905
+ closeWithError(/* @__PURE__ */ new Error(`WebSocket ${redactUrl(url)}: очередь кадров переполнена`), 4e3, "queue overflow");
10906
+ return;
10907
+ }
10908
+ pendingFrames += 1;
10343
10909
  messageQueue = messageQueue.then(async () => {
10910
+ pendingFrames -= 1;
10344
10911
  if (settled || discardMessages) return;
10345
10912
  const decoded = await pending;
10346
10913
  if ("error" in decoded) {
@@ -10633,6 +11200,6 @@ function renderSpans(content, spans = [], options = {}) {
10633
11200
  return result;
10634
11201
  }
10635
11202
  //#endregion
10636
- export { ALLOWED_MIME_TYPES, AUDIO_MIME_TYPES, AUTH_FLAG_COOKIE, AUTH_PATHS, AccessType, AttachmentType, BUILT_IN_SERVICES, CommentSort, DEFAULT_BASE_URL, DEFAULT_FILE_STREAM_BUFFER_BYTES, DEFAULT_STATUS_BASE_URL, DEFAULT_TIMEOUT, DEFAULT_UPLOAD_TIMEOUT, DEFAULT_URL_FILE_MAX_BYTES, DEFAULT_USER_AGENT, DEVICE_ID_HEADER, DetectedRuntime, FeedTab, FileTransferMode, IMAGE_MIME_TYPES, IncidentKind, InteractionType, ItdAbortError, ItdAccounts, ItdApiError, ItdApiErrorKind, ItdAuthError, ItdClient, ItdConfigError, ItdConflictError, ItdError, ItdErrorCode, ItdErrorKind, ItdFileError, ItdFileErrorReason, ItdForbiddenError, ItdNetworkError, ItdNotFoundError, ItdPhoneVerificationError, ItdRateLimitError, ItdRealtime, ItdServerError, ItdStateError, ItdTimeoutError, ItdValidationError, LIBRARY_VERSION, LikesVisibility, MAX_RECONNECT_ATTEMPTS, MemoryKeyValueStore, MemoryMultiTokenStorage, MemoryTokenStorage, NOTIFICATION_TYPE_ALIASES, NotificationType, OPERATIONS, PaginationMode, Paginator, RECONNECT_BACKOFF, RECONNECT_JITTER, REFRESH_COOKIE, REFRESH_COOKIE_PATH, RealtimeComposer, RealtimeRouter, RealtimeStatus, RealtimeTransportKind, RealtimeUpdateOrigin, RealtimeUpdateType, ReportReason, ReportTargetType, RetrySafety, RuntimeMode, STATUS_SERVICE, STREAM_PATH, ServiceRegistry, ServiceState, SignInStatus, SpanRenderFormat, SpanType, TURNSTILE_SITE_KEY, UnauthorizedStreamError, VIDEO_MIME_TYPES, ViewReason, ViewSource, WEBSOCKET_PATH, WallAccess, WebSocketTransport, autoSpans, canonicalNotificationType, comment, createAccounts, createClient, createKeyValueStore, createMultiTokenStorage, createRecordKeyValueStore, createTokenStorage, formatNotificationText, fromStream, fromUrl, isBuilder, isBuiltInOperationId, isEnumerableKeyValueStore, isItdApiError, isItdAuthError, isItdConflictError, isItdError, isItdFileError, isItdForbiddenError, isItdNotFoundError, isItdPhoneVerificationError, isItdRateLimitError, isItdServerError, isItdStateError, isItdValidationError, isKnownNotificationType, isMyProfile, mapPage, markup, normalizeNotification, operationMethod, operationRetrySafety, parseHtml, parseMarkdown, poll, post, readNotificationEvent, readUnreadCountEvent, renderSpans, report, resolveNotificationUrl, runRealtimeMiddleware, scopedTokenStorage, statusDays, systemClock, toDate, utcStampToIso, withCodec, withNamespace };
11203
+ export { ALLOWED_MIME_TYPES, AUDIO_MIME_TYPES, AccessType, AttachmentType, BUCKET_LIMITS, CommentSort, DEFAULT_BASE_URL, DEFAULT_RATE_LIMIT_BUCKET, FeedTab, FileTransferMode, IMAGE_MIME_TYPES, IncidentKind, InteractionType, ItdAbortError, ItdAccounts, ItdApiError, ItdApiErrorKind, ItdAuthError, ItdClient, ItdConfigError, ItdConflictError, ItdError, ItdErrorCode, ItdErrorKind, ItdFileError, ItdFileErrorReason, ItdForbiddenError, ItdNetworkError, ItdNotFoundError, ItdPhoneVerificationError, ItdRateLimitError, ItdRealtime, ItdServerError, ItdStateError, ItdTimeoutError, ItdValidationError, LIBRARY_VERSION, LikesVisibility, MemoryKeyValueStore, MemoryMultiTokenStorage, MemoryTokenStorage, NotificationType, OPERATIONS, PaginationMode, Paginator, RateLimitPacing, RealtimeComposer, RealtimeRouter, RealtimeStatus, RealtimeTransportKind, RealtimeUpdateOrigin, RealtimeUpdateType, ReportReason, ReportTargetType, RetrySafety, RuntimeMode, STATUS_SERVICE, ServiceState, SignInStatus, SpanRenderFormat, SpanType, TURNSTILE_SITE_KEY, UnauthorizedStreamError, VIDEO_MIME_TYPES, ViewReason, ViewSource, WallAccess, WebSocketTransport, autoSpans, canonicalNotificationType, comment, createAccounts, createClient, createKeyValueStore, createMultiTokenStorage, createRecordKeyValueStore, createTokenStorage, formatNotificationText, fromStream, fromUrl, isBuilder, isBuiltInOperationId, isEnumerableKeyValueStore, isItdApiError, isItdAuthError, isItdConflictError, isItdError, isItdFileError, isItdForbiddenError, isItdNotFoundError, isItdPhoneVerificationError, isItdRateLimitError, isItdServerError, isItdStateError, isItdValidationError, isKnownNotificationType, isMyProfile, mapPage, markup, normalizeNotification, operationBucket, operationMethod, operationRetrySafety, parseHtml, parseMarkdown, poll, post, renderSpans, report, resolveNotificationUrl, runRealtimeMiddleware, scopedTokenStorage, statusDays, systemClock, toDate, utcStampToIso, withCodec, withNamespace };
10637
11204
 
10638
11205
  //# sourceMappingURL=index.js.map