itd-api 0.5.0 → 0.6.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
  * Минимальный типизированный источник событий.
@@ -434,6 +434,14 @@ const OPERATIONS = freezeOperations({
434
434
  method: "PUT",
435
435
  retrySafety: RetrySafety.Idempotent
436
436
  },
437
+ "realtime.poll.updates": {
438
+ method: "GET",
439
+ retrySafety: RetrySafety.Safe
440
+ },
441
+ "realtime.poll.unread": {
442
+ method: "GET",
443
+ retrySafety: RetrySafety.Safe
444
+ },
437
445
  "hashtags.search": {
438
446
  method: "GET",
439
447
  retrySafety: RetrySafety.Safe
@@ -667,6 +675,11 @@ const AUTH_PATHS = {
667
675
  * Нужен, чтобы отрисовать виджет капчи и получить токен для `signIn`, `signUp`
668
676
  * и `forgotPassword`.
669
677
  *
678
+ * Ключ привязан к домену: на чужом origin Cloudflare отказывает виджету с кодом `110200`.
679
+ * Поэтому отрисовать его может только код, выполняемый на самом итд.com. Остальным
680
+ * подходит `@itd-api/turnstile`, готовый токен из другого источника или вовсе вход
681
+ * без капчи — по сохранённой сессии либо по токенам, взятым в браузере.
682
+ *
670
683
  * @example
671
684
  * ```ts
672
685
  * turnstile.render('#captcha', {
@@ -676,8 +689,6 @@ const AUTH_PATHS = {
676
689
  * ```
677
690
  */
678
691
  const TURNSTILE_SITE_KEY = "0x4AAAAAACHhxczw6fJGwPBg";
679
- /** Заголовок с идентификатором устройства. Сервер связывает с ним запись в списке сессий. */
680
- const DEVICE_ID_HEADER = "X-Device-Id";
681
692
  function readAccessToken(payload) {
682
693
  if (typeof payload !== "object" || payload === null) return void 0;
683
694
  const token = payload.accessToken;
@@ -1182,10 +1193,33 @@ const systemClock = Object.freeze({
1182
1193
  return () => clearTimeout(timer);
1183
1194
  }
1184
1195
  });
1196
+ /**
1197
+ * Заводит срок, общий на все ожидания: от ожидания к ожиданию он не продлевается.
1198
+ *
1199
+ * @param timeout срок в миллисекундах; `0` — ждать без ограничения
1200
+ * @internal
1201
+ */
1202
+ function createDeadline(timeout, clock = systemClock) {
1203
+ if (timeout <= 0) return {
1204
+ wait: async (promise) => {
1205
+ await promise;
1206
+ return true;
1207
+ },
1208
+ cancel: () => {}
1209
+ };
1210
+ let expire;
1211
+ const expired = new Promise((resolve) => {
1212
+ expire = resolve;
1213
+ });
1214
+ return {
1215
+ wait: (promise) => Promise.race([promise.then(() => true), expired.then(() => false)]),
1216
+ cancel: clock.schedule(() => expire(), timeout)
1217
+ };
1218
+ }
1185
1219
  //#endregion
1186
1220
  //#region src/core/version.ts
1187
1221
  /** Версия библиотеки. Попадает в `User-Agent`. */
1188
- const LIBRARY_VERSION = "0.5.0";
1222
+ const LIBRARY_VERSION = "0.6.0";
1189
1223
  //#endregion
1190
1224
  //#region src/core/config.ts
1191
1225
  /** Базовый URL API итд.com. Домен записан в punycode: `итд.com`. */
@@ -1200,8 +1234,6 @@ const BUILT_IN_SERVICES = Object.freeze([Object.freeze({
1200
1234
  baseUrl: DEFAULT_STATUS_BASE_URL,
1201
1235
  auth: false
1202
1236
  })]);
1203
- /** Таймаут запроса по умолчанию — 30 секунд. */
1204
- const DEFAULT_TIMEOUT = 3e4;
1205
1237
  /**
1206
1238
  * `User-Agent` по умолчанию.
1207
1239
  *
@@ -1411,6 +1443,7 @@ function resolveConfig(options = {}) {
1411
1443
  const mode = options.mode ?? RuntimeMode.Auto;
1412
1444
  if (!Object.values(RuntimeMode).includes(mode)) throw new ItdConfigError(`mode должен быть одним из ${Object.values(RuntimeMode).join(", ")}, получено: ${mode}`);
1413
1445
  const timeout = requirePositive(options.timeout ?? 3e4, "timeout");
1446
+ const shutdownTimeout = requirePositive(options.shutdownTimeout ?? 1e4, "shutdownTimeout");
1414
1447
  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
1448
  requireOptionalBoolean(options.autoRefresh, "autoRefresh");
1416
1449
  requireOptionalBoolean(options.reloginOnRefreshFailure, "reloginOnRefreshFailure");
@@ -1426,6 +1459,7 @@ function resolveConfig(options = {}) {
1426
1459
  fetch: resolveFetch(options.fetch),
1427
1460
  clock: options.clock ?? systemClock,
1428
1461
  timeout,
1462
+ shutdownTimeout,
1429
1463
  retry: resolveRetry(options.retry),
1430
1464
  rateLimit: resolveRateLimit(options.rateLimit),
1431
1465
  hooks: resolveHooks(options.hooks),
@@ -1994,7 +2028,11 @@ var PluginRegistry = class {
1994
2028
  #entries = /* @__PURE__ */ new Map();
1995
2029
  #removing = /* @__PURE__ */ new Set();
1996
2030
  #cleanups = /* @__PURE__ */ new Set();
2031
+ #options;
1997
2032
  #ordered = [];
2033
+ constructor(options) {
2034
+ this.#options = options;
2035
+ }
1998
2036
  /** Сколько плагинов подключено. */
1999
2037
  get size() {
2000
2038
  return this.#entries.size;
@@ -2068,9 +2106,11 @@ var PluginRegistry = class {
2068
2106
  * Отключает плагин и вызывает его функцию очистки.
2069
2107
  *
2070
2108
  * Новые запросы перестают видеть расширения плагина сразу. Снимок уже начавшейся операции,
2071
- * включая её будущие retry, остаётся неизменным; очистка дождётся завершения операции.
2109
+ * включая её будущие retry, остаётся неизменным; очистка дождётся завершения операции,
2110
+ * но не дольше отведённого срока.
2072
2111
  *
2073
2112
  * @returns `false`, если такого плагина не было
2113
+ * @throws {ItdStateError} если операции плагина не завершились за отведённый срок
2074
2114
  */
2075
2115
  async remove(name) {
2076
2116
  const entry = this.#entries.get(name);
@@ -2079,21 +2119,24 @@ var PluginRegistry = class {
2079
2119
  this.#entries.delete(name);
2080
2120
  this.#ordered = this.#ordered.filter((current) => current !== entry);
2081
2121
  this.#removing.add(name);
2122
+ const deadline = createDeadline(this.#options.shutdownTimeout, this.#options.clock);
2082
2123
  const cleanup = this.#trackCleanup((async () => {
2083
- await this.#waitForDrain(entry);
2084
- await entry.teardown?.();
2124
+ const expired = await this.#release(entry, deadline);
2125
+ if (expired) throw expired;
2085
2126
  })());
2086
2127
  try {
2087
2128
  await cleanup;
2088
2129
  return true;
2089
2130
  } finally {
2131
+ deadline.cancel();
2090
2132
  this.#removing.delete(name);
2091
2133
  }
2092
2134
  }
2093
2135
  /**
2094
2136
  * Отключает все плагины окончательно.
2095
2137
  *
2096
- * Очистка идёт изнутри наружу — в порядке, обратном выполнению расширений.
2138
+ * Очистка идёт изнутри наружу — в порядке, обратном выполнению расширений. Срок ожидания
2139
+ * общий на все плагины.
2097
2140
  */
2098
2141
  async dispose() {
2099
2142
  const entries = [...this.#ordered].reverse();
@@ -2101,13 +2144,14 @@ var PluginRegistry = class {
2101
2144
  this.#entries.clear();
2102
2145
  this.#ordered = [];
2103
2146
  for (const { plugin } of entries) this.#removing.add(plugin.name);
2104
- await this.#trackCleanup((async () => {
2147
+ const deadline = createDeadline(this.#options.shutdownTimeout, this.#options.clock);
2148
+ const cleanup = this.#trackCleanup((async () => {
2105
2149
  const errors = [];
2106
2150
  const previous = await Promise.allSettled(previousCleanups);
2107
2151
  for (const result of previous) if (result.status === "rejected") errors.push(result.reason);
2108
2152
  for (const entry of entries) try {
2109
- await this.#waitForDrain(entry);
2110
- await entry.teardown?.();
2153
+ const expired = await this.#release(entry, deadline);
2154
+ if (expired) errors.push(expired);
2111
2155
  } catch (error) {
2112
2156
  errors.push(error);
2113
2157
  } finally {
@@ -2115,6 +2159,11 @@ var PluginRegistry = class {
2115
2159
  }
2116
2160
  if (errors.length > 0) throw new AggregateError(errors, "Не удалось освободить ресурсы плагинов");
2117
2161
  })());
2162
+ try {
2163
+ await cleanup;
2164
+ } finally {
2165
+ deadline.cancel();
2166
+ }
2118
2167
  }
2119
2168
  /**
2120
2169
  * Прогоняет запрос через operation transformers и прикрепляет attempt interceptors.
@@ -2122,6 +2171,9 @@ var PluginRegistry = class {
2122
2171
  * Снимки обеих цепочек берутся в начале: `unuse()` влияет на новые операции, но не меняет
2123
2172
  * уже выполняющуюся и не удаляет interceptors из её последующих retry.
2124
2173
  *
2174
+ * Каждый `next` одноразовый: transformer может завершить операцию сам, но не может породить
2175
+ * вторую — для `posts.create` это была бы вторая публикация.
2176
+ *
2125
2177
  * @param execute выполнение логической операции, вызываемое самым внутренним transformer
2126
2178
  */
2127
2179
  async run(request, execute) {
@@ -2132,7 +2184,17 @@ var PluginRegistry = class {
2132
2184
  interceptor
2133
2185
  })));
2134
2186
  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)));
2187
+ const chain = entries.flatMap((entry) => entry.transformers.map((transformer) => ({
2188
+ plugin: entry.plugin.name,
2189
+ transformer
2190
+ }))).reduceRight((next, { plugin, transformer }) => (current) => {
2191
+ let called = false;
2192
+ return transformer(scoped(current), (prepared) => {
2193
+ if (called) throw new ItdConfigError(`operation transformer плагина «${plugin}» вызвал next() больше одного раза`);
2194
+ called = true;
2195
+ return next(scoped(prepared));
2196
+ });
2197
+ }, (current) => execute(scoped(current)));
2136
2198
  try {
2137
2199
  return await chain(scoped(request));
2138
2200
  } finally {
@@ -2146,6 +2208,18 @@ var PluginRegistry = class {
2146
2208
  }
2147
2209
  }
2148
2210
  }
2211
+ /**
2212
+ * Дожидается операций плагина и освобождает его ресурсы.
2213
+ *
2214
+ * `teardown` выполняется в любом случае, в том числе после истечения срока.
2215
+ *
2216
+ * @returns ошибка истёкшего срока, если ждать пришлось дольше отведённого
2217
+ */
2218
+ async #release(entry, deadline) {
2219
+ const finished = await deadline.wait(this.#waitForDrain(entry));
2220
+ await entry.teardown?.();
2221
+ return finished ? void 0 : new ItdStateError(`плагин «${entry.plugin.name}» не завершил операции за ${this.#options.shutdownTimeout} мс; ожидание прекращено, ресурсы плагина освобождены`);
2222
+ }
2149
2223
  #waitForDrain(entry) {
2150
2224
  if (entry.activeRequests === 0) return Promise.resolve();
2151
2225
  entry.drain ??= new Promise((resolve) => {
@@ -2318,6 +2392,7 @@ var RequestQueuePool = class {
2318
2392
  stop() {
2319
2393
  this.#main.stop();
2320
2394
  for (const queue of this.#byDestination.values()) queue.stop();
2395
+ this.#byDestination.clear();
2321
2396
  }
2322
2397
  };
2323
2398
  //#endregion
@@ -2842,17 +2917,25 @@ function abortable(promise, signal) {
2842
2917
  });
2843
2918
  }
2844
2919
  /**
2845
- * Объединяет пользовательский `AbortSignal` с таймаутом.
2920
+ * Объединяет пользовательский `AbortSignal`, сигнал жизни клиента и таймаут.
2846
2921
  *
2847
2922
  * Реализовано вручную, а не через `AbortSignal.any`: последний появился только в Node 20,
2848
- * а библиотека поддерживает Node 18.
2923
+ * а библиотека поддерживает Node 18. Причина отмены переносится от источника как есть.
2849
2924
  */
2850
- function createAbortBundle(userSignal, timeout, clock) {
2925
+ function createAbortBundle(userSignal, lifetimeSignal, timeout, clock) {
2851
2926
  const controller = new AbortController();
2852
2927
  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 });
2928
+ const link = (source) => {
2929
+ if (!source) return void 0;
2930
+ if (source.aborted) {
2931
+ controller.abort(source.reason);
2932
+ return;
2933
+ }
2934
+ const onAbort = () => controller.abort(source.reason);
2935
+ source.addEventListener("abort", onAbort, { once: true });
2936
+ return () => source.removeEventListener("abort", onAbort);
2937
+ };
2938
+ const unlink = [link(userSignal), link(lifetimeSignal)];
2856
2939
  const cancelTimer = timeout > 0 ? clock.schedule(() => {
2857
2940
  timedOut = true;
2858
2941
  controller.abort();
@@ -2862,7 +2945,7 @@ function createAbortBundle(userSignal, timeout, clock) {
2862
2945
  timedOut: () => timedOut,
2863
2946
  cleanup: () => {
2864
2947
  cancelTimer?.();
2865
- userSignal?.removeEventListener("abort", onUserAbort);
2948
+ for (const detach of unlink) detach?.();
2866
2949
  }
2867
2950
  };
2868
2951
  }
@@ -2899,7 +2982,7 @@ var Transport = class {
2899
2982
  const headers = await this.#buildHeaders(request, url);
2900
2983
  const attempt = request.attempt ?? 1;
2901
2984
  const timeout = request.timeout ?? this.#config.timeout;
2902
- const abort = createAbortBundle(request.signal, timeout, this.#config.clock);
2985
+ const abort = createAbortBundle(request.signal, this.#deps.lifetimeSignal, timeout, this.#config.clock);
2903
2986
  const startedAt = this.#config.clock.now();
2904
2987
  let cleanupBody;
2905
2988
  try {
@@ -3116,7 +3199,7 @@ var Transport = class {
3116
3199
  path: request.path
3117
3200
  });
3118
3201
  if (aborted) {
3119
- const reason = request.signal?.reason;
3202
+ const reason = abort.signal.reason;
3120
3203
  return new ItdAbortError(`Запрос ${method} ${request.path} отменён`, reason !== void 0 ? { cause: reason } : void 0);
3121
3204
  }
3122
3205
  if (error instanceof ItdError) return error;
@@ -3157,8 +3240,12 @@ function createServiceRegistry(config) {
3157
3240
  function createClientRuntime(options = {}, internals = {}) {
3158
3241
  const config = resolveConfig(options);
3159
3242
  const jar = new CookieJar();
3160
- const plugins = new PluginRegistry();
3243
+ const plugins = new PluginRegistry({
3244
+ shutdownTimeout: config.shutdownTimeout,
3245
+ clock: config.clock
3246
+ });
3161
3247
  const services = createServiceRegistry(config);
3248
+ const lifetime = new AbortController();
3162
3249
  const sharedQueues = config.rateLimit ? internals.queues : void 0;
3163
3250
  const queues = sharedQueues ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit, config.clock) : void 0);
3164
3251
  const ownsQueues = sharedQueues === void 0;
@@ -3188,7 +3275,8 @@ function createClientRuntime(options = {}, internals = {}) {
3188
3275
  }, {
3189
3276
  cookies: config.useCookieJar ? jar : void 0,
3190
3277
  getDeviceId: () => auth.getDeviceId(),
3191
- onRateLimit: queues && config.rateLimit?.respectHeaders ? throttleByHeaders : void 0
3278
+ onRateLimit: queues && config.rateLimit?.respectHeaders ? throttleByHeaders : void 0,
3279
+ lifetimeSignal: lifetime.signal
3192
3280
  });
3193
3281
  const stages = [
3194
3282
  {
@@ -3265,9 +3353,10 @@ function createClientRuntime(options = {}, internals = {}) {
3265
3353
  close: () => {
3266
3354
  if (ownsQueues) queues?.stop();
3267
3355
  },
3268
- dispose: () => {
3356
+ dispose: async () => {
3357
+ lifetime.abort(new ItdAbortError("Клиент освобождён через dispose(), запрос отменён"));
3269
3358
  auth.dispose();
3270
- return plugins.dispose();
3359
+ await plugins.dispose();
3271
3360
  }
3272
3361
  };
3273
3362
  }
@@ -3625,7 +3714,12 @@ var RealtimeDispatcher = class {
3625
3714
  if (index >= 0) this.#handlers.splice(index, 1);
3626
3715
  };
3627
3716
  }
3628
- dispatch(context) {
3717
+ /**
3718
+ * Принимает обновление к обработке.
3719
+ *
3720
+ * @param coalesceKey ожидающая работа с тем же ключом заменяется новой
3721
+ */
3722
+ dispatch(context, coalesceKey) {
3629
3723
  let keys;
3630
3724
  let middleware;
3631
3725
  try {
@@ -3635,12 +3729,20 @@ var RealtimeDispatcher = class {
3635
3729
  this.#hooks.middlewareError(error, context);
3636
3730
  return;
3637
3731
  }
3638
- this.#queue.push({
3732
+ const replaced = coalesceKey === void 0 ? -1 : this.#queue.findIndex((pending) => pending.coalesceKey === coalesceKey);
3733
+ if (replaced < 0 && this.#queue.length >= 256) {
3734
+ this.#hooks.overflow();
3735
+ return;
3736
+ }
3737
+ const work = {
3639
3738
  context,
3640
3739
  middleware,
3641
3740
  handlers: [...this.#handlers],
3642
- keys
3643
- });
3741
+ keys,
3742
+ coalesceKey
3743
+ };
3744
+ if (replaced >= 0) this.#queue[replaced] = work;
3745
+ else this.#queue.push(work);
3644
3746
  this.#pump();
3645
3747
  }
3646
3748
  /** Отбрасывает обновления, обработка которых ещё не началась. */
@@ -3717,14 +3819,6 @@ const RECONNECT_BACKOFF = Object.freeze([
3717
3819
  16e3,
3718
3820
  3e4
3719
3821
  ]);
3720
- /** Доля случайного разброса паузы. */
3721
- const RECONNECT_JITTER = .3;
3722
- /**
3723
- * Сколько раз пытаться переподключиться подряд.
3724
- *
3725
- * После исчерпания поток сообщает `giveup` и ждёт ручного `connect()`.
3726
- */
3727
- const MAX_RECONNECT_ATTEMPTS = 15;
3728
3822
  /**
3729
3823
  * Вычисляет паузу перед попыткой переподключения.
3730
3824
  *
@@ -4025,7 +4119,8 @@ var RealtimeEngine = class {
4025
4119
  }, {
4026
4120
  deliver: (context) => this.#deps.deliver(context.update),
4027
4121
  middlewareError: (error, context) => this.#reportDispatchError("middlewareError", error, context),
4028
- handlerError: (error, context) => this.#reportDispatchError("handlerError", error, context)
4122
+ handlerError: (error, context) => this.#reportDispatchError("handlerError", error, context),
4123
+ overflow: () => this.#handleOverflow()
4029
4124
  });
4030
4125
  }
4031
4126
  /** Текущее состояние соединения. */
@@ -4106,7 +4201,23 @@ var RealtimeEngine = class {
4106
4201
  /** Пропускает актуальное обновление через цепочку обработчиков. */
4107
4202
  #dispatch(update, raw, origin) {
4108
4203
  if (!this.#wanted) return;
4109
- this.#dispatcher.dispatch(this.#deps.createContext(update, raw, origin));
4204
+ this.#dispatcher.dispatch(this.#deps.createContext(update, raw, origin), this.#deps.coalesceKey?.(update));
4205
+ }
4206
+ /**
4207
+ * Очередь обновлений достигла предела: закрывает соединение и переподключается,
4208
+ * когда обработчики разберут очередь.
4209
+ */
4210
+ #handleOverflow() {
4211
+ const controller = this.#controller;
4212
+ if (!this.#wanted || !controller) return;
4213
+ const generation = this.#generation;
4214
+ controller.abort();
4215
+ this.#controller = void 0;
4216
+ this.#setStatus(RealtimeStatus.Error);
4217
+ const error = /* @__PURE__ */ new Error("Обработчики не успевают за потоком: очередь обновлений переполнена");
4218
+ this.#dispatcher.drain().then(() => {
4219
+ if (this.#isCurrentGeneration(generation) && !this.#controller) this.#scheduleReconnect(error);
4220
+ });
4110
4221
  }
4111
4222
  /** Выполняет доменную синхронизацию, не роняя подключение из-за её ошибки. */
4112
4223
  async #sync(reason, generation, starting) {
@@ -4131,6 +4242,7 @@ var RealtimeEngine = class {
4131
4242
  baseUrl: this.#deps.baseUrl,
4132
4243
  authorize: this.#deps.authorize,
4133
4244
  fetch: this.#deps.fetch,
4245
+ request: this.#deps.request,
4134
4246
  baseHeaders: this.#deps.baseHeaders,
4135
4247
  getToken: this.#deps.getToken,
4136
4248
  signal: controller.signal,
@@ -4229,7 +4341,12 @@ var RealtimeEngine = class {
4229
4341
  this.#starting = void 0;
4230
4342
  if (this.#isCurrentGeneration(generation)) this.#run(generation);
4231
4343
  }
4232
- /** Завершает автоматические попытки переподключения. */
4344
+ /**
4345
+ * Завершает автоматические попытки переподключения.
4346
+ *
4347
+ * Владелец узнаёт об этом так же, как при {@link disconnect}. `onClose` вызывается
4348
+ * до событий: обработчик `giveup` может тут же вызвать `connect()`.
4349
+ */
4233
4350
  #giveUp(error) {
4234
4351
  this.#wanted = false;
4235
4352
  this.#generation += 1;
@@ -4237,6 +4354,7 @@ var RealtimeEngine = class {
4237
4354
  this.#attempt = 0;
4238
4355
  this.#detachEnvironment?.();
4239
4356
  this.#detachEnvironment = void 0;
4357
+ this.#deps.onClose?.();
4240
4358
  this.#emitEngine("error", {
4241
4359
  error,
4242
4360
  willReconnect: false
@@ -4309,26 +4427,15 @@ var PollTransport = class {
4309
4427
  this.#limit = options.limit ?? 20;
4310
4428
  }
4311
4429
  async connect(context) {
4430
+ const request = context.request;
4431
+ if (!request) throw new ItdConfigError("опрос уведомлений выполняется через конвейер клиента; создайте поток вызовом itd.realtime()");
4312
4432
  const seen = /* @__PURE__ */ new Set();
4313
4433
  let firstRun = true;
4314
4434
  let lastUnreadCount;
4315
4435
  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}`);
4436
+ const payload = await this.#readUpdates(request, context.signal);
4329
4437
  context.onOpen();
4330
- const body = await response.json();
4331
- const items = pickArray(typeof body === "object" && body !== null && "data" in body ? body.data : body, "notifications");
4438
+ const items = pickArray(payload, "notifications");
4332
4439
  for (const item of [...items].reverse()) {
4333
4440
  const id = typeof item.id === "string" ? item.id : void 0;
4334
4441
  if (!id || seen.has(id)) continue;
@@ -4342,7 +4449,7 @@ var PollTransport = class {
4342
4449
  const excess = [...seen].slice(0, seen.size - this.#limit * 2);
4343
4450
  for (const id of excess) seen.delete(id);
4344
4451
  }
4345
- const count = await this.#readCount(context, headers);
4452
+ const count = await this.#readCount(request, context.signal);
4346
4453
  if (count !== void 0 && count !== lastUnreadCount) {
4347
4454
  lastUnreadCount = count;
4348
4455
  context.onEvent({
@@ -4354,16 +4461,29 @@ var PollTransport = class {
4354
4461
  await this.#wait(context.signal);
4355
4462
  }
4356
4463
  }
4357
- async #readCount(context, headers) {
4464
+ async #readUpdates(request, signal) {
4358
4465
  try {
4359
- const response = await context.fetch(joinUrl(context.baseUrl, "/api/notifications/count"), {
4360
- method: "GET",
4361
- headers,
4362
- signal: context.signal
4466
+ return await request({
4467
+ operationId: "realtime.poll.updates",
4468
+ path: "/api/notifications/",
4469
+ query: {
4470
+ limit: this.#limit,
4471
+ offset: 0
4472
+ },
4473
+ signal
4363
4474
  });
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);
4475
+ } catch (error) {
4476
+ if (isItdAuthError(error)) throw new UnauthorizedStreamError();
4477
+ throw error;
4478
+ }
4479
+ }
4480
+ async #readCount(request, signal) {
4481
+ try {
4482
+ return pickNumber(await request({
4483
+ operationId: "realtime.poll.unread",
4484
+ path: "/api/notifications/count",
4485
+ signal
4486
+ }), "count", 0);
4367
4487
  } catch {
4368
4488
  return;
4369
4489
  }
@@ -4723,6 +4843,7 @@ var ItdRealtime = class {
4723
4843
  baseUrl: deps.baseUrl,
4724
4844
  authorize: deps.authorize ?? true,
4725
4845
  fetch: deps.fetch,
4846
+ request: deps.request,
4726
4847
  clock: deps.clock,
4727
4848
  baseHeaders: deps.baseHeaders,
4728
4849
  getToken: deps.getToken,
@@ -4733,6 +4854,7 @@ var ItdRealtime = class {
4733
4854
  transport: createTransport(deps, options),
4734
4855
  handleFrame: (event) => this.#handleFrame(event),
4735
4856
  readUpdate: readRealtimeUpdate,
4857
+ coalesceKey: (update) => update.type === RealtimeUpdateType.UnreadCount ? update.type : void 0,
4736
4858
  createContext: (update, raw, origin) => ({
4737
4859
  update,
4738
4860
  stream: this,
@@ -6009,10 +6131,6 @@ function createMultipartFileBody(file) {
6009
6131
  cancel
6010
6132
  };
6011
6133
  }
6012
- //#endregion
6013
- //#region src/resources/files.ts
6014
- /** Таймаут одной попытки загрузки файла — 5 минут. */
6015
- const DEFAULT_UPLOAD_TIMEOUT = 3e5;
6016
6134
  /** Файлы и медиа. */
6017
6135
  var FilesResource = class extends BaseResource {
6018
6136
  #fetch;
@@ -9094,6 +9212,11 @@ var ItdClient = class ItdClient {
9094
9212
  stream = new ItdRealtime({
9095
9213
  baseUrl: this.#runtime.config.baseUrl,
9096
9214
  fetch: this.#runtime.config.fetch,
9215
+ request: ({ operationId, path, query, signal }) => this.#runtime.http.operation(operationId, {
9216
+ path,
9217
+ query,
9218
+ signal
9219
+ }),
9097
9220
  baseHeaders: (url) => this.#runtime.platformHeaders(url),
9098
9221
  getAuthIdentity: () => this.#runtime.auth.getCurrentAuthIdentity(),
9099
9222
  getAuthScope: () => this.#runtime.auth.getAuthScope(),
@@ -9113,13 +9236,16 @@ var ItdClient = class ItdClient {
9113
9236
  * Освобождает ресурсы клиента: закрывает все потоки уведомлений, отправляет открытые
9114
9237
  * накопители {@link telemetry}, затем останавливает очередь запросов.
9115
9238
  *
9116
- * Метод дожидается активных обработчиков потока. После вызова клиентом можно пользоваться
9117
- * снова; ранее созданный поток можно запустить повторным `connect()`.
9239
+ * Метод дожидается активных обработчиков потока, но не дольше `shutdownTimeout`. После
9240
+ * вызова клиентом можно пользоваться снова; ранее созданный поток можно запустить
9241
+ * повторным `connect()`.
9118
9242
  *
9119
9243
  * Общая очередь, полученная от {@link ItdAccounts}, не останавливается: её гасит сам
9120
9244
  * контейнер, когда закрывает все аккаунты разом.
9121
9245
  *
9122
9246
  * Терминальное освобождение — это {@link dispose}.
9247
+ *
9248
+ * @throws {ItdStateError} если обработчики потока не завершились за отведённый срок
9123
9249
  */
9124
9250
  async close() {
9125
9251
  return this.#close(false);
@@ -9127,22 +9253,30 @@ var ItdClient = class ItdClient {
9127
9253
  async #close(disposeCleanup) {
9128
9254
  const streams = this.#disconnectStreams();
9129
9255
  if (disposeCleanup) prepareTelemetryForDispose(this.#telemetry);
9256
+ const { shutdownTimeout, clock } = this.#runtime.config;
9257
+ const deadline = createDeadline(shutdownTimeout, clock);
9258
+ let stuck = [];
9130
9259
  try {
9131
- await Promise.all(streams.map((stream) => stream.drain()));
9260
+ stuck = (await Promise.all(streams.map(async (stream) => await deadline.wait(stream.drain()) ? void 0 : stream))).filter((stream) => stream !== void 0);
9132
9261
  if (disposeCleanup) await closeTelemetryForDispose(this.#telemetry);
9133
9262
  else await this.#telemetry?.close();
9134
9263
  } finally {
9264
+ deadline.cancel();
9135
9265
  this.#runtime.close();
9136
9266
  }
9267
+ if (stuck.length > 0) throw new ItdStateError(`обработчики потоков (${stuck.map((stream) => stream.transport).join(", ")}) не завершились за ${shutdownTimeout} мс; ожидание прекращено`);
9137
9268
  }
9138
9269
  /**
9139
- * Окончательно освобождает клиент: выполняет {@link close} и отключает все плагины.
9270
+ * Окончательно освобождает клиент: выполняет {@link close}, отменяет незавершённые
9271
+ * запросы и отключает все плагины.
9140
9272
  *
9141
9273
  * Терминальное состояние устанавливается сразу при первом вызове. После этого новые
9142
9274
  * запросы, подключение плагинов, регистрация сервисов и создание или повторный запуск
9143
9275
  * realtime-потоков завершаются с {@link ItdStateError}. Повторные вызовы возвращают
9144
9276
  * тот же результат очистки.
9145
9277
  *
9278
+ * Ожидание обработчиков потока и операций плагинов ограничено `shutdownTimeout`.
9279
+ *
9146
9280
  * @example
9147
9281
  * ```ts
9148
9282
  * await using itd = new ItdClient({ auth: token });
@@ -10121,10 +10255,6 @@ var RealtimeComposer = class RealtimeComposer {
10121
10255
  return (context, next) => runRealtimeMiddleware(snapshot, context, next);
10122
10256
  }
10123
10257
  };
10124
- //#endregion
10125
- //#region src/realtime/websocket.ts
10126
- /** Стандартный путь WebSocket-подключения. */
10127
- const WEBSOCKET_PATH = "/api/ws";
10128
10258
  const CONNECTING = 0;
10129
10259
  const OPEN = 1;
10130
10260
  const NORMAL_CLOSURE = 1e3;
@@ -10262,6 +10392,7 @@ var WebSocketTransport = class {
10262
10392
  let discardMessages = false;
10263
10393
  let socketError;
10264
10394
  let messageQueue = Promise.resolve();
10395
+ let pendingFrames = 0;
10265
10396
  let cancelHandshake;
10266
10397
  let cancelIdle;
10267
10398
  let cancelKeepAlive;
@@ -10340,7 +10471,13 @@ var WebSocketTransport = class {
10340
10471
  pending = Promise.resolve({ error });
10341
10472
  }
10342
10473
  if (pending === void 0) return;
10474
+ if (pendingFrames >= 256) {
10475
+ closeWithError(/* @__PURE__ */ new Error(`WebSocket ${redactUrl(url)}: очередь кадров переполнена`), 4e3, "queue overflow");
10476
+ return;
10477
+ }
10478
+ pendingFrames += 1;
10343
10479
  messageQueue = messageQueue.then(async () => {
10480
+ pendingFrames -= 1;
10344
10481
  if (settled || discardMessages) return;
10345
10482
  const decoded = await pending;
10346
10483
  if ("error" in decoded) {
@@ -10633,6 +10770,6 @@ function renderSpans(content, spans = [], options = {}) {
10633
10770
  return result;
10634
10771
  }
10635
10772
  //#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 };
10773
+ export { ALLOWED_MIME_TYPES, AUDIO_MIME_TYPES, AccessType, AttachmentType, CommentSort, DEFAULT_BASE_URL, 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, 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, operationMethod, operationRetrySafety, parseHtml, parseMarkdown, poll, post, renderSpans, report, resolveNotificationUrl, runRealtimeMiddleware, scopedTokenStorage, statusDays, systemClock, toDate, utcStampToIso, withCodec, withNamespace };
10637
10774
 
10638
10775
  //# sourceMappingURL=index.js.map