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