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