itd-api 0.2.0 → 0.3.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
@@ -419,7 +419,7 @@ var AuthManager = class {
419
419
  await this.#saveSession({
420
420
  ...this.#session ?? {},
421
421
  accessToken,
422
- obtainedAt: Date.now()
422
+ obtainedAt: this.#config.clock.now()
423
423
  });
424
424
  this.#emitter.emit("tokens", { accessToken });
425
425
  }
@@ -504,12 +504,12 @@ var AuthManager = class {
504
504
  if (!auth) return null;
505
505
  if (typeof auth === "string") return {
506
506
  accessToken: auth,
507
- obtainedAt: Date.now()
507
+ obtainedAt: this.#config.clock.now()
508
508
  };
509
509
  if ("accessToken" in auth) return {
510
510
  accessToken: auth.accessToken,
511
511
  refreshToken: auth.refreshToken,
512
- obtainedAt: Date.now()
512
+ obtainedAt: this.#config.clock.now()
513
513
  };
514
514
  return null;
515
515
  }
@@ -557,7 +557,7 @@ var AuthManager = class {
557
557
  ...this.#session ?? {},
558
558
  accessToken,
559
559
  ...rotated ? { refreshToken: rotated } : {},
560
- obtainedAt: Date.now()
560
+ obtainedAt: this.#config.clock.now()
561
561
  });
562
562
  this.#emitter.emit("tokens", { accessToken });
563
563
  return accessToken;
@@ -632,7 +632,7 @@ var AuthManager = class {
632
632
  this.#transitionAuth(accessToken);
633
633
  await this.#saveSession({
634
634
  accessToken,
635
- obtainedAt: Date.now()
635
+ obtainedAt: this.#config.clock.now()
636
636
  });
637
637
  this.#emitter.emit("tokens", { accessToken });
638
638
  this.#emitter.emit("signIn", { accessToken });
@@ -640,6 +640,16 @@ var AuthManager = class {
640
640
  }
641
641
  };
642
642
  //#endregion
643
+ //#region src/core/clock.ts
644
+ /** Системные часы, используемые клиентом по умолчанию. */
645
+ const systemClock = Object.freeze({
646
+ now: () => Date.now(),
647
+ schedule(callback, delay) {
648
+ const timer = setTimeout(callback, delay);
649
+ return () => clearTimeout(timer);
650
+ }
651
+ });
652
+ //#endregion
643
653
  //#region src/core/url.ts
644
654
  /**
645
655
  * Собирает строку запроса.
@@ -717,7 +727,7 @@ function normalizeBaseUrl(baseUrl) {
717
727
  //#endregion
718
728
  //#region src/core/version.ts
719
729
  /** Версия библиотеки. Попадает в `User-Agent`. */
720
- const LIBRARY_VERSION = "0.2.0";
730
+ const LIBRARY_VERSION = "0.3.0";
721
731
  //#endregion
722
732
  //#region src/core/config.ts
723
733
  /** Базовый URL API итд.com. Домен записан в punycode: `итд.com`. */
@@ -945,6 +955,7 @@ function resolveConfig(options = {}) {
945
955
  const mode = options.mode ?? require_runtime.RuntimeMode.Auto;
946
956
  if (!Object.values(require_runtime.RuntimeMode).includes(mode)) throw new require_storage.ItdConfigError(`mode должен быть одним из ${Object.values(require_runtime.RuntimeMode).join(", ")}, получено: ${mode}`);
947
957
  const timeout = requirePositive(options.timeout ?? 3e4, "timeout");
958
+ 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()");
948
959
  requireOptionalBoolean(options.autoRefresh, "autoRefresh");
949
960
  requireOptionalBoolean(options.reloginOnRefreshFailure, "reloginOnRefreshFailure");
950
961
  if (options.userAgent !== void 0 && options.userAgent !== false && typeof options.userAgent !== "string") throw new require_storage.ItdConfigError("userAgent должен быть строкой или false");
@@ -957,6 +968,7 @@ function resolveConfig(options = {}) {
957
968
  autoRefresh: options.autoRefresh ?? true,
958
969
  reloginOnRefreshFailure: options.reloginOnRefreshFailure ?? true,
959
970
  fetch: require_runtime.resolveFetch(options.fetch),
971
+ clock: options.clock ?? systemClock,
960
972
  timeout,
961
973
  retry: resolveRetry(options.retry),
962
974
  rateLimit: resolveRateLimit(options.rateLimit),
@@ -1467,16 +1479,16 @@ function createRetryScheduler(options, random = Math.random) {
1467
1479
  //#endregion
1468
1480
  //#region src/core/middleware.ts
1469
1481
  /** Ожидание повтора, которое уважает отмену запроса. */
1470
- function sleep(ms, signal) {
1471
- if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
1482
+ function sleep(clock, ms, signal) {
1483
+ if (!signal) return new Promise((resolve) => clock.schedule(resolve, ms));
1472
1484
  if (signal.aborted) return Promise.reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1473
1485
  return new Promise((resolve, reject) => {
1474
- const timer = setTimeout(() => {
1486
+ const cancel = clock.schedule(() => {
1475
1487
  signal.removeEventListener("abort", onAbort);
1476
1488
  resolve();
1477
1489
  }, ms);
1478
1490
  const onAbort = () => {
1479
- clearTimeout(timer);
1491
+ cancel();
1480
1492
  reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1481
1493
  };
1482
1494
  signal.addEventListener("abort", onAbort, { once: true });
@@ -1620,7 +1632,7 @@ function createRetryMiddleware(deps) {
1620
1632
  delay
1621
1633
  }, request);
1622
1634
  deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${attempt + 1} через ${delay} мс`);
1623
- await sleep(delay, request.signal);
1635
+ await sleep(deps.clock ?? systemClock, delay, request.signal);
1624
1636
  }
1625
1637
  };
1626
1638
  }
@@ -1649,10 +1661,12 @@ var RequestQueue = class {
1649
1661
  #active = 0;
1650
1662
  /** Момент, раньше которого следующий запрос стартовать не должен. */
1651
1663
  #nextSlot = 0;
1652
- #timer;
1653
- constructor(options) {
1664
+ #clock;
1665
+ #cancelTimer;
1666
+ constructor(options, clock = systemClock) {
1654
1667
  this.#concurrency = options.concurrency;
1655
1668
  this.#minGap = options.rps ? 1e3 / options.rps : 0;
1669
+ this.#clock = clock;
1656
1670
  }
1657
1671
  /** Сколько задач выполняется прямо сейчас. */
1658
1672
  get active() {
@@ -1704,9 +1718,9 @@ var RequestQueue = class {
1704
1718
  * ошибкой `ItdAbortError`. Уже выполняющиеся задачи доводятся до конца.
1705
1719
  */
1706
1720
  stop() {
1707
- if (this.#timer !== void 0) {
1708
- clearTimeout(this.#timer);
1709
- this.#timer = void 0;
1721
+ if (this.#cancelTimer) {
1722
+ this.#cancelTimer();
1723
+ this.#cancelTimer = void 0;
1710
1724
  }
1711
1725
  this.#nextSlot = 0;
1712
1726
  const pending = this.#waiting.splice(0, this.#waiting.length);
@@ -1720,23 +1734,23 @@ var RequestQueue = class {
1720
1734
  */
1721
1735
  pause(ms) {
1722
1736
  if (ms <= 0) return;
1723
- this.#nextSlot = Math.max(this.#nextSlot, Date.now() + ms);
1737
+ this.#nextSlot = Math.max(this.#nextSlot, this.#clock.now() + ms);
1724
1738
  }
1725
1739
  /** Запускает столько ожидающих задач, сколько позволяют ограничения. */
1726
1740
  #drain() {
1727
1741
  if (this.#waiting.length === 0) {
1728
- if (this.#timer !== void 0) {
1729
- clearTimeout(this.#timer);
1730
- this.#timer = void 0;
1742
+ if (this.#cancelTimer) {
1743
+ this.#cancelTimer();
1744
+ this.#cancelTimer = void 0;
1731
1745
  }
1732
1746
  return;
1733
1747
  }
1734
1748
  if (this.#active >= this.#concurrency) return;
1735
- if (this.#timer !== void 0) return;
1736
- const now = Date.now();
1749
+ if (this.#cancelTimer) return;
1750
+ const now = this.#clock.now();
1737
1751
  if (this.#nextSlot > now) {
1738
- this.#timer = setTimeout(() => {
1739
- this.#timer = void 0;
1752
+ this.#cancelTimer = this.#clock.schedule(() => {
1753
+ this.#cancelTimer = void 0;
1740
1754
  this.#drain();
1741
1755
  }, this.#nextSlot - now);
1742
1756
  return;
@@ -1755,19 +1769,21 @@ var RequestQueue = class {
1755
1769
  */
1756
1770
  var RequestQueuePool = class {
1757
1771
  #options;
1772
+ #clock;
1758
1773
  #main;
1759
1774
  /** Очереди сервисов заводятся при первом запросе — обычно не нужна ни одна. */
1760
1775
  #byService = /* @__PURE__ */ new Map();
1761
- constructor(options) {
1776
+ constructor(options, clock = systemClock) {
1762
1777
  this.#options = options;
1763
- this.#main = new RequestQueue(options);
1778
+ this.#clock = clock;
1779
+ this.#main = new RequestQueue(options, clock);
1764
1780
  }
1765
1781
  /** Очередь хоста. */
1766
1782
  for(service) {
1767
1783
  if (service === void 0) return this.#main;
1768
1784
  let queue = this.#byService.get(service);
1769
1785
  if (!queue) {
1770
- queue = new RequestQueue(this.#options);
1786
+ queue = new RequestQueue(this.#options, this.#clock);
1771
1787
  this.#byService.set(service, queue);
1772
1788
  }
1773
1789
  return queue;
@@ -2215,7 +2231,7 @@ function createApiError(context) {
2215
2231
  path: context.path,
2216
2232
  raw: safeRawBody(context.body),
2217
2233
  response: context.response,
2218
- retryAfter: parseRetryAfter(context.headers?.get("retry-after"))
2234
+ retryAfter: parseRetryAfter(context.headers?.get("retry-after"), context.now)
2219
2235
  };
2220
2236
  if (parsed.code === "PHONE_VERIFICATION_REQUIRED") return new require_storage.ItdPhoneVerificationError({
2221
2237
  ...init,
@@ -2291,13 +2307,13 @@ function abortable(promise, signal) {
2291
2307
  * Реализовано вручную, а не через `AbortSignal.any`: последний появился только в Node 20,
2292
2308
  * а библиотека поддерживает Node 18.
2293
2309
  */
2294
- function createAbortBundle(userSignal, timeout) {
2310
+ function createAbortBundle(userSignal, timeout, clock) {
2295
2311
  const controller = new AbortController();
2296
2312
  let timedOut = false;
2297
2313
  const onUserAbort = () => controller.abort(userSignal?.reason);
2298
2314
  if (userSignal) if (userSignal.aborted) controller.abort(userSignal.reason);
2299
2315
  else userSignal.addEventListener("abort", onUserAbort, { once: true });
2300
- const timer = timeout > 0 ? setTimeout(() => {
2316
+ const cancelTimer = timeout > 0 ? clock.schedule(() => {
2301
2317
  timedOut = true;
2302
2318
  controller.abort();
2303
2319
  }, timeout) : void 0;
@@ -2305,7 +2321,7 @@ function createAbortBundle(userSignal, timeout) {
2305
2321
  signal: controller.signal,
2306
2322
  timedOut: () => timedOut,
2307
2323
  cleanup: () => {
2308
- if (timer !== void 0) clearTimeout(timer);
2324
+ cancelTimer?.();
2309
2325
  userSignal?.removeEventListener("abort", onUserAbort);
2310
2326
  }
2311
2327
  };
@@ -2342,8 +2358,8 @@ var Transport = class {
2342
2358
  const headers = await this.#buildHeaders(request, url);
2343
2359
  const attempt = request.attempt ?? 1;
2344
2360
  const timeout = request.timeout ?? this.#config.timeout;
2345
- const abort = createAbortBundle(request.signal, timeout);
2346
- const startedAt = Date.now();
2361
+ const abort = createAbortBundle(request.signal, timeout, this.#config.clock);
2362
+ const startedAt = this.#config.clock.now();
2347
2363
  let cleanupBody;
2348
2364
  try {
2349
2365
  let body;
@@ -2362,7 +2378,7 @@ var Transport = class {
2362
2378
  };
2363
2379
  await dispatchRequestHook(this.#config.hooks, "onError", {
2364
2380
  ...context,
2365
- duration: Date.now() - startedAt,
2381
+ duration: this.#config.clock.now() - startedAt,
2366
2382
  error: failure
2367
2383
  }, request);
2368
2384
  throw failure;
@@ -2391,7 +2407,7 @@ var Transport = class {
2391
2407
  if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) init.duplex = "half";
2392
2408
  response = await this.#config.fetch(url, init);
2393
2409
  } catch (error) {
2394
- const duration = Date.now() - startedAt;
2410
+ const duration = this.#config.clock.now() - startedAt;
2395
2411
  const failure = this.#toTransportError(error, abort, request, method, timeout);
2396
2412
  await dispatchRequestHook(this.#config.hooks, "onError", {
2397
2413
  ...context,
@@ -2409,16 +2425,17 @@ var Transport = class {
2409
2425
  if (response.ok) await dispatchRequestHook(this.#config.hooks, "onResponse", {
2410
2426
  ...context,
2411
2427
  status: response.status,
2412
- duration: Date.now() - startedAt,
2428
+ duration: this.#config.clock.now() - startedAt,
2413
2429
  response
2414
2430
  }, request);
2415
2431
  const payload = await this.#readBodyOrFail(response, context, request, method, abort, timeout);
2416
- const duration = Date.now() - startedAt;
2432
+ const duration = this.#config.clock.now() - startedAt;
2417
2433
  if (!response.ok) {
2418
2434
  const error = createApiError({
2419
2435
  method,
2420
2436
  path: request.path,
2421
2437
  status: response.status,
2438
+ now: this.#config.clock.now(),
2422
2439
  statusText: response.statusText,
2423
2440
  headers: response.headers,
2424
2441
  response,
@@ -2487,7 +2504,7 @@ var Transport = class {
2487
2504
  }
2488
2505
  /** Читает тело и преобразует ошибку чтения в транспортную ошибку библиотеки. */
2489
2506
  async #readBodyOrFail(response, context, request, method, abort, timeout) {
2490
- const startedAt = Date.now();
2507
+ const startedAt = this.#config.clock.now();
2491
2508
  try {
2492
2509
  return await abortable(readBody(response), abort.signal);
2493
2510
  } catch (error) {
@@ -2495,7 +2512,7 @@ var Transport = class {
2495
2512
  const failure = this.#toTransportError(error, abort, request, method, timeout);
2496
2513
  await dispatchRequestHook(this.#config.hooks, "onError", {
2497
2514
  ...context,
2498
- duration: Date.now() - startedAt,
2515
+ duration: this.#config.clock.now() - startedAt,
2499
2516
  error: failure
2500
2517
  }, request);
2501
2518
  this.#config.logger?.warn(`× ${method} ${request.path}: не удалось прочитать тело ответа — ${failure.message}`);
@@ -2816,144 +2833,167 @@ const ItdErrorCode = Object.freeze({
2816
2833
  WRITE_ACCESS_RESTRICTED: "WRITE_ACCESS_RESTRICTED"
2817
2834
  });
2818
2835
  //#endregion
2819
- //#region src/notifications/type-map.ts
2820
- /**
2821
- * Соответствие коротких имён типов уведомлений развёрнутым.
2822
- *
2823
- * Сервер и в списке, и в потоке событий — присылает короткие имена: `like`, `comment`,
2824
- * `reply`, `repost`, `comment_like`. Развёрнутые (`post_reaction`, `post_comment`)
2825
- * встречаются в оформлении интерфейса, поэтому библиотека приводит типы к ним:
2826
- * они однозначно называют и объект, и действие.
2827
- *
2828
- * Пришедшее значение всегда остаётся в поле `rawType`.
2829
- */
2830
- const NOTIFICATION_TYPE_ALIASES = Object.freeze({
2831
- like: NotificationType.PostReaction,
2832
- comment: NotificationType.PostComment,
2833
- comment_like: NotificationType.CommentReaction,
2834
- reply: NotificationType.CommentReply,
2835
- repost: NotificationType.PostRepost,
2836
- mention: NotificationType.PostMention
2837
- });
2838
- const KNOWN_TYPES = new Set(Object.values(NotificationType));
2839
- /**
2840
- * Приводит имя типа к каноническому.
2841
- *
2842
- * Неизвестное значение возвращается без изменений, чтобы не менять смысл нового типа
2843
- * уведомления на другой.
2844
- *
2845
- * @example
2846
- * ```ts
2847
- * canonicalNotificationType('like'); // 'post_reaction'
2848
- * canonicalNotificationType('post_reaction'); // 'post_reaction'
2849
- * canonicalNotificationType('новое_событие'); // 'новое_событие'
2850
- * ```
2851
- */
2852
- function canonicalNotificationType(rawType) {
2853
- return NOTIFICATION_TYPE_ALIASES[rawType] ?? rawType;
2854
- }
2855
- /**
2856
- * Известен ли библиотеке этот тип уведомления.
2857
- *
2858
- * Полезно, чтобы решить, показывать ли уведомление, для которого нет своего оформления.
2859
- */
2860
- function isKnownNotificationType(type) {
2861
- return KNOWN_TYPES.has(canonicalNotificationType(type));
2862
- }
2863
- //#endregion
2864
- //#region src/notifications/normalize.ts
2865
- function asActor(value) {
2866
- if (!isRecord(value)) return void 0;
2867
- const id = asString(value.id);
2868
- if (!id) return void 0;
2869
- return {
2870
- id,
2871
- username: asString(value.username) ?? "",
2872
- displayName: asString(value.displayName) ?? "",
2873
- avatar: asString(value.avatar) ?? "",
2874
- ...typeof value.isFollowing === "boolean" ? { isFollowing: value.isFollowing } : {},
2875
- ...typeof value.isFollowedBy === "boolean" ? { isFollowedBy: value.isFollowedBy } : {}
2876
- };
2877
- }
2878
- /** Собирает участников: сервер присылает либо одного `actor`, либо массив `actors`. */
2879
- function readActors(source) {
2880
- if (Array.isArray(source.actors)) return source.actors.map(asActor).filter((actor) => actor !== void 0);
2881
- const single = asActor(source.actor);
2882
- return single ? [single] : [];
2883
- }
2884
- /**
2885
- * Приводит уведомление к единой форме.
2886
- *
2887
- * Нужна потому, что REST-список и поток событий описывают одно и то же событие по-разному:
2888
- * различаются имена типов (`like` против `post_reaction`), имена полей
2889
- * (`targetId`/`entityId`, `read`/`isRead`, `preview`/`entityPreview`) и число участников
2890
- * (`actor` против массива `actors`). После приведения объекты из обоих источников
2891
- * можно складывать в один список.
2892
- *
2893
- * Исходные данные не теряются: имя типа с сервера остаётся в `rawType`,
2894
- * весь объект целиком — в `raw`.
2895
- *
2896
- * @param input уведомление из REST-ответа либо полезная нагрузка события потока
2897
- *
2898
- * @example
2899
- * ```ts
2900
- * const fromRest = normalizeNotification(restItem);
2901
- * const fromStream = normalizeNotification(event.payload);
2902
- * // одинаковая форма — можно объединять
2903
- * ```
2904
- */
2905
- function normalizeNotification(input) {
2906
- const source = isRecord(input) ? input : {};
2907
- const payload = isRecord(source.payload) ? source.payload : source;
2908
- const rawType = asString(payload.type) ?? asString(source.type) ?? "";
2909
- const createdAt = asString(payload.createdAt) ?? asString(source.createdAt) ?? "";
2910
- const readAt = asString(payload.readAt) ?? asString(source.readAt);
2911
- const isRead = typeof payload.isRead === "boolean" ? payload.isRead : typeof payload.read === "boolean" ? payload.read : Boolean(readAt);
2912
- const subjectId = asString(payload.subjectId);
2913
- const targetId = asString(payload.targetId);
2914
- const subjectIsComment = payload.subjectType === "comment";
2915
- const clickUrl = asString(payload.clickUrl);
2916
- return {
2917
- id: asString(payload.id) ?? asString(source.id) ?? "",
2918
- type: canonicalNotificationType(rawType),
2919
- rawType,
2920
- entityId: asString(payload.entityId) ?? (subjectIsComment ? subjectId ?? targetId : targetId) ?? null,
2921
- parentEntityId: asString(payload.parentEntityId) ?? (subjectIsComment ? targetId ?? null : null),
2922
- isRead,
2923
- actors: readActors(payload),
2924
- count: typeof payload.count === "number" && payload.count > 0 ? payload.count : 1,
2925
- preview: asString(payload.entityPreview) ?? asString(payload.preview) ?? null,
2926
- ...clickUrl ? { clickUrl } : {},
2927
- createdAt,
2928
- updatedAt: asString(payload.updatedAt) ?? readAt ?? createdAt,
2929
- raw: input
2930
- };
2931
- }
2932
- /**
2933
- * Разбирает событие `notification` из потока.
2934
- *
2935
- * Кроме самого уведомления событие несёт служебные поля уровня конверта: актуальный
2936
- * счётчик непрочитанных и признак звука.
2937
- */
2938
- function readNotificationEvent(data) {
2939
- const source = isRecord(data) ? data : {};
2940
- return {
2941
- notification: normalizeNotification(data),
2942
- unreadCount: typeof source.unreadCount === "number" ? source.unreadCount : void 0,
2943
- sound: source.sound === true
2836
+ //#region src/realtime/middleware.ts
2837
+ const REALTIME_MIDDLEWARE_SNAPSHOT = Symbol("itd-api.realtime.middlewareSnapshot");
2838
+ /** @internal */
2839
+ function captureRealtimeMiddleware(middleware) {
2840
+ const capture = middleware[REALTIME_MIDDLEWARE_SNAPSHOT];
2841
+ if (!capture) return middleware;
2842
+ const snapshot = capture();
2843
+ if (typeof snapshot !== "function") throw new TypeError("Снимок промежуточного обработчика должен быть функцией");
2844
+ return snapshot;
2845
+ }
2846
+ /** @internal */
2847
+ function withRealtimeMiddlewareSnapshot(middleware, capture) {
2848
+ Object.defineProperty(middleware, REALTIME_MIDDLEWARE_SNAPSHOT, { value: capture });
2849
+ return middleware;
2850
+ }
2851
+ /** Выполняет промежуточные обработчики по порядку и запрещает повторный вызов `next()`. */
2852
+ async function runRealtimeMiddleware(middleware, context, terminal) {
2853
+ let lastIndex = -1;
2854
+ const dispatch = async (index) => {
2855
+ if (index <= lastIndex) throw new Error("next() в промежуточном обработчике вызван повторно");
2856
+ lastIndex = index;
2857
+ const current = middleware[index];
2858
+ if (!current) {
2859
+ await terminal();
2860
+ return;
2861
+ }
2862
+ let downstream;
2863
+ let duplicateCalls;
2864
+ let failure;
2865
+ const next = () => {
2866
+ if (!downstream) {
2867
+ downstream = dispatch(index + 1);
2868
+ return downstream;
2869
+ }
2870
+ const duplicate = Promise.reject(/* @__PURE__ */ new Error("next() в одном промежуточном обработчике вызван повторно"));
2871
+ duplicateCalls = Promise.all(duplicateCalls ? [duplicateCalls, duplicate] : [duplicate]).then(() => void 0);
2872
+ return duplicate;
2873
+ };
2874
+ try {
2875
+ await current(context, next);
2876
+ } catch (error) {
2877
+ failure = { error };
2878
+ }
2879
+ try {
2880
+ await downstream;
2881
+ await duplicateCalls;
2882
+ } catch (error) {
2883
+ failure ??= { error };
2884
+ }
2885
+ if (failure) throw failure.error;
2944
2886
  };
2887
+ await dispatch(0);
2945
2888
  }
2946
- /**
2947
- * Разбирает событие `unread_count` из потока.
2948
- *
2949
- * Возвращает `undefined`, если сервер прислал событие без вложенного `payload`.
2950
- */
2951
- function readUnreadCountEvent(data) {
2952
- if (!isRecord(data)) return void 0;
2953
- const payload = isRecord(data.payload) ? data.payload : void 0;
2954
- if (!payload) return void 0;
2955
- return typeof payload.count === "number" ? payload.count : void 0;
2956
- }
2889
+ /** Планирует нормализованные обновления и отслеживает незавершённые обработчики. */
2890
+ var RealtimeDispatcher = class {
2891
+ #options;
2892
+ #hooks;
2893
+ #middleware = [];
2894
+ #handlers = [];
2895
+ #queue = [];
2896
+ #activeKeys = /* @__PURE__ */ new Set();
2897
+ #drainWaiters = /* @__PURE__ */ new Set();
2898
+ #active = 0;
2899
+ constructor(options, hooks) {
2900
+ this.#options = options;
2901
+ this.#hooks = hooks;
2902
+ }
2903
+ use(middleware) {
2904
+ this.#middleware.push(middleware);
2905
+ return () => {
2906
+ const index = this.#middleware.indexOf(middleware);
2907
+ if (index >= 0) this.#middleware.splice(index, 1);
2908
+ };
2909
+ }
2910
+ on(predicate, handler) {
2911
+ const registration = {
2912
+ predicate,
2913
+ handler
2914
+ };
2915
+ this.#handlers.push(registration);
2916
+ return () => {
2917
+ const index = this.#handlers.indexOf(registration);
2918
+ if (index >= 0) this.#handlers.splice(index, 1);
2919
+ };
2920
+ }
2921
+ dispatch(context) {
2922
+ let keys;
2923
+ let middleware;
2924
+ try {
2925
+ keys = this.#keysFor(context);
2926
+ middleware = this.#middleware.map(captureRealtimeMiddleware);
2927
+ } catch (error) {
2928
+ this.#hooks.middlewareError(error, context);
2929
+ return;
2930
+ }
2931
+ this.#queue.push({
2932
+ context,
2933
+ middleware,
2934
+ handlers: [...this.#handlers],
2935
+ keys
2936
+ });
2937
+ this.#pump();
2938
+ }
2939
+ /** Отбрасывает обновления, обработка которых ещё не началась. */
2940
+ clearPending() {
2941
+ this.#queue.length = 0;
2942
+ this.#resolveDrain();
2943
+ }
2944
+ /** Ждёт завершения активных и поставленных в очередь обновлений. */
2945
+ drain() {
2946
+ if (this.#active === 0 && this.#queue.length === 0) return Promise.resolve();
2947
+ return new Promise((resolve) => this.#drainWaiters.add(resolve));
2948
+ }
2949
+ #keysFor(context) {
2950
+ const value = this.#options.sequentialize?.(context);
2951
+ if (value === void 0) return [];
2952
+ const values = Array.isArray(value) ? value : [value];
2953
+ for (const key of values) if (typeof key !== "string" && typeof key !== "number" && typeof key !== "symbol") throw new TypeError("sequentialize() должен возвращать PropertyKey или их список");
2954
+ return [...new Set(values)];
2955
+ }
2956
+ #pump() {
2957
+ while (this.#active < this.#options.concurrency) {
2958
+ const blockedKeys = new Set(this.#activeKeys);
2959
+ const index = this.#queue.findIndex(({ keys }) => {
2960
+ const runnable = keys.every((key) => !blockedKeys.has(key));
2961
+ if (!runnable) for (const key of keys) blockedKeys.add(key);
2962
+ return runnable;
2963
+ });
2964
+ if (index < 0) break;
2965
+ const [work] = this.#queue.splice(index, 1);
2966
+ if (!work) break;
2967
+ this.#active += 1;
2968
+ for (const key of work.keys) this.#activeKeys.add(key);
2969
+ this.#run(work);
2970
+ }
2971
+ this.#resolveDrain();
2972
+ }
2973
+ async #run(work) {
2974
+ try {
2975
+ await runRealtimeMiddleware(work.middleware, work.context, async () => {
2976
+ for (const { predicate, handler } of work.handlers) try {
2977
+ if (predicate(work.context)) await handler(work.context);
2978
+ } catch (error) {
2979
+ this.#hooks.handlerError(error, work.context);
2980
+ }
2981
+ this.#hooks.deliver(work.context);
2982
+ });
2983
+ } catch (error) {
2984
+ this.#hooks.middlewareError(error, work.context);
2985
+ } finally {
2986
+ this.#active -= 1;
2987
+ for (const key of work.keys) this.#activeKeys.delete(key);
2988
+ this.#pump();
2989
+ }
2990
+ }
2991
+ #resolveDrain() {
2992
+ if (this.#active !== 0 || this.#queue.length !== 0) return;
2993
+ for (const resolve of this.#drainWaiters) resolve();
2994
+ this.#drainWaiters.clear();
2995
+ }
2996
+ };
2957
2997
  //#endregion
2958
2998
  //#region src/realtime/transport.ts
2959
2999
  /** Ошибка, по которой видно, что сервер отверг авторизацию потока. */
@@ -2979,7 +3019,9 @@ var PollTransport = class {
2979
3019
  name = "poll";
2980
3020
  #interval;
2981
3021
  #limit;
3022
+ #clock;
2982
3023
  constructor(options = {}) {
3024
+ this.#clock = options.clock ?? systemClock;
2983
3025
  this.#interval = options.interval ?? 15e3;
2984
3026
  this.#limit = options.limit ?? 20;
2985
3027
  }
@@ -3047,9 +3089,9 @@ var PollTransport = class {
3047
3089
  #wait(signal) {
3048
3090
  if (signal.aborted) return Promise.resolve();
3049
3091
  return new Promise((resolve) => {
3050
- const timer = setTimeout(finish, this.#interval);
3092
+ const cancel = this.#clock.schedule(finish, this.#interval);
3051
3093
  function finish() {
3052
- clearTimeout(timer);
3094
+ cancel();
3053
3095
  signal.removeEventListener("abort", finish);
3054
3096
  resolve();
3055
3097
  }
@@ -3286,9 +3328,11 @@ var SseTransport = class {
3286
3328
  name = "sse";
3287
3329
  #idleTimeout;
3288
3330
  #handshakeTimeout;
3331
+ #clock;
3289
3332
  /** Идентификатор последнего события — отправляется при переподключении. */
3290
3333
  #lastEventId;
3291
3334
  constructor(options = {}) {
3335
+ this.#clock = options.clock ?? systemClock;
3292
3336
  this.#idleTimeout = options.idleTimeout ?? 9e4;
3293
3337
  this.#handshakeTimeout = options.handshakeTimeout ?? 2e4;
3294
3338
  }
@@ -3319,7 +3363,7 @@ var SseTransport = class {
3319
3363
  /** Выполняет запрос потока, обрывая его, если ответ не пришёл за отведённое время. */
3320
3364
  async #handshake(url, headers, context, controller) {
3321
3365
  let expired = false;
3322
- const timer = this.#handshakeTimeout > 0 ? setTimeout(() => {
3366
+ const cancelTimer = this.#handshakeTimeout > 0 ? this.#clock.schedule(() => {
3323
3367
  expired = true;
3324
3368
  controller.abort(/* @__PURE__ */ new Error("Истёк таймаут рукопожатия SSE"));
3325
3369
  }, this.#handshakeTimeout) : void 0;
@@ -3333,7 +3377,7 @@ var SseTransport = class {
3333
3377
  if (expired) throw new Error("Поток уведомлений не ответил: истёк таймаут рукопожатия");
3334
3378
  throw error;
3335
3379
  } finally {
3336
- if (timer !== void 0) clearTimeout(timer);
3380
+ cancelTimer?.();
3337
3381
  }
3338
3382
  }
3339
3383
  async #read(body, context) {
@@ -3354,11 +3398,11 @@ var SseTransport = class {
3354
3398
  data
3355
3399
  });
3356
3400
  } });
3357
- let idleTimer;
3401
+ let cancelIdleTimer;
3358
3402
  const armIdleTimer = () => {
3359
3403
  if (this.#idleTimeout <= 0) return;
3360
- if (idleTimer !== void 0) clearTimeout(idleTimer);
3361
- idleTimer = setTimeout(() => {
3404
+ cancelIdleTimer?.();
3405
+ cancelIdleTimer = this.#clock.schedule(() => {
3362
3406
  reader.cancel(/* @__PURE__ */ new Error("Поток молчит дольше допустимого")).catch(() => {});
3363
3407
  }, this.#idleTimeout);
3364
3408
  };
@@ -3371,12 +3415,220 @@ var SseTransport = class {
3371
3415
  parser.feed(decoder.decode(value, { stream: true }));
3372
3416
  }
3373
3417
  } finally {
3374
- if (idleTimer !== void 0) clearTimeout(idleTimer);
3418
+ cancelIdleTimer?.();
3375
3419
  reader.releaseLock?.();
3376
3420
  }
3377
3421
  }
3378
3422
  };
3379
3423
  //#endregion
3424
+ //#region src/notifications/type-map.ts
3425
+ /**
3426
+ * Соответствие коротких имён типов уведомлений развёрнутым.
3427
+ *
3428
+ * Сервер — и в списке, и в потоке событий — присылает короткие имена: `like`, `comment`,
3429
+ * `reply`, `repost`, `comment_like`. Развёрнутые (`post_reaction`, `post_comment`)
3430
+ * встречаются в оформлении интерфейса, поэтому библиотека приводит типы к ним:
3431
+ * они однозначно называют и объект, и действие.
3432
+ *
3433
+ * Пришедшее значение всегда остаётся в поле `rawType`.
3434
+ */
3435
+ const NOTIFICATION_TYPE_ALIASES = Object.freeze({
3436
+ like: NotificationType.PostReaction,
3437
+ comment: NotificationType.PostComment,
3438
+ comment_like: NotificationType.CommentReaction,
3439
+ reply: NotificationType.CommentReply,
3440
+ repost: NotificationType.PostRepost,
3441
+ mention: NotificationType.PostMention
3442
+ });
3443
+ const KNOWN_TYPES = new Set(Object.values(NotificationType));
3444
+ /**
3445
+ * Приводит имя типа к каноническому.
3446
+ *
3447
+ * Неизвестное значение возвращается без изменений, чтобы не менять смысл нового типа
3448
+ * уведомления на другой.
3449
+ *
3450
+ * @example
3451
+ * ```ts
3452
+ * canonicalNotificationType('like'); // 'post_reaction'
3453
+ * canonicalNotificationType('post_reaction'); // 'post_reaction'
3454
+ * canonicalNotificationType('новое_событие'); // 'новое_событие'
3455
+ * ```
3456
+ */
3457
+ function canonicalNotificationType(rawType) {
3458
+ return NOTIFICATION_TYPE_ALIASES[rawType] ?? rawType;
3459
+ }
3460
+ /**
3461
+ * Известен ли библиотеке этот тип уведомления.
3462
+ *
3463
+ * Полезно, чтобы решить, показывать ли уведомление, для которого нет своего оформления.
3464
+ */
3465
+ function isKnownNotificationType(type) {
3466
+ return KNOWN_TYPES.has(canonicalNotificationType(type));
3467
+ }
3468
+ //#endregion
3469
+ //#region src/notifications/normalize.ts
3470
+ function asActor(value) {
3471
+ if (!isRecord(value)) return void 0;
3472
+ const id = asString(value.id);
3473
+ if (!id) return void 0;
3474
+ return {
3475
+ id,
3476
+ username: asString(value.username) ?? "",
3477
+ displayName: asString(value.displayName) ?? "",
3478
+ avatar: asString(value.avatar) ?? "",
3479
+ ...typeof value.isFollowing === "boolean" ? { isFollowing: value.isFollowing } : {},
3480
+ ...typeof value.isFollowedBy === "boolean" ? { isFollowedBy: value.isFollowedBy } : {}
3481
+ };
3482
+ }
3483
+ /** Собирает участников: сервер присылает либо одного `actor`, либо массив `actors`. */
3484
+ function readActors(source) {
3485
+ if (Array.isArray(source.actors)) return source.actors.map(asActor).filter((actor) => actor !== void 0);
3486
+ const single = asActor(source.actor);
3487
+ return single ? [single] : [];
3488
+ }
3489
+ /**
3490
+ * Приводит уведомление к единой форме.
3491
+ *
3492
+ * Нужна потому, что REST-список и поток событий описывают одно и то же событие по-разному:
3493
+ * различаются имена типов (`like` против `post_reaction`), имена полей
3494
+ * (`targetId`/`entityId`, `read`/`isRead`, `preview`/`entityPreview`) и число участников
3495
+ * (`actor` против массива `actors`). После приведения объекты из обоих источников
3496
+ * можно складывать в один список.
3497
+ *
3498
+ * Исходные данные не теряются: имя типа с сервера остаётся в `rawType`,
3499
+ * весь объект целиком — в `raw`.
3500
+ *
3501
+ * @param input уведомление из REST-ответа либо полезная нагрузка события потока
3502
+ *
3503
+ * @example
3504
+ * ```ts
3505
+ * const fromRest = normalizeNotification(restItem);
3506
+ * const fromStream = normalizeNotification(event.payload);
3507
+ * // одинаковая форма — можно объединять
3508
+ * ```
3509
+ */
3510
+ function normalizeNotification(input) {
3511
+ const source = isRecord(input) ? input : {};
3512
+ const payload = isRecord(source.payload) ? source.payload : source;
3513
+ const rawType = asString(payload.type) ?? asString(source.type) ?? "";
3514
+ const createdAt = asString(payload.createdAt) ?? asString(source.createdAt) ?? "";
3515
+ const readAt = asString(payload.readAt) ?? asString(source.readAt);
3516
+ const isRead = typeof payload.isRead === "boolean" ? payload.isRead : typeof payload.read === "boolean" ? payload.read : Boolean(readAt);
3517
+ const subjectId = asString(payload.subjectId);
3518
+ const targetId = asString(payload.targetId);
3519
+ const subjectIsComment = payload.subjectType === "comment";
3520
+ const clickUrl = asString(payload.clickUrl);
3521
+ return {
3522
+ id: asString(payload.id) ?? asString(source.id) ?? "",
3523
+ type: canonicalNotificationType(rawType),
3524
+ rawType,
3525
+ entityId: asString(payload.entityId) ?? (subjectIsComment ? subjectId ?? targetId : targetId) ?? null,
3526
+ parentEntityId: asString(payload.parentEntityId) ?? (subjectIsComment ? targetId ?? null : null),
3527
+ isRead,
3528
+ actors: readActors(payload),
3529
+ count: typeof payload.count === "number" && payload.count > 0 ? payload.count : 1,
3530
+ preview: asString(payload.entityPreview) ?? asString(payload.preview) ?? null,
3531
+ ...clickUrl ? { clickUrl } : {},
3532
+ createdAt,
3533
+ updatedAt: asString(payload.updatedAt) ?? readAt ?? createdAt,
3534
+ raw: input
3535
+ };
3536
+ }
3537
+ /**
3538
+ * Разбирает событие `notification` из потока.
3539
+ *
3540
+ * Кроме самого уведомления событие несёт служебные поля уровня конверта: актуальный
3541
+ * счётчик непрочитанных и признак звука.
3542
+ */
3543
+ function readNotificationEvent(data) {
3544
+ const source = isRecord(data) ? data : {};
3545
+ return {
3546
+ notification: normalizeNotification(data),
3547
+ unreadCount: typeof source.unreadCount === "number" ? source.unreadCount : void 0,
3548
+ sound: source.sound === true
3549
+ };
3550
+ }
3551
+ /**
3552
+ * Разбирает событие `unread_count` из потока.
3553
+ *
3554
+ * Возвращает `undefined`, если сервер прислал событие без вложенного `payload`.
3555
+ */
3556
+ function readUnreadCountEvent(data) {
3557
+ if (!isRecord(data)) return void 0;
3558
+ const payload = isRecord(data.payload) ? data.payload : void 0;
3559
+ if (!payload) return void 0;
3560
+ return typeof payload.count === "number" ? payload.count : void 0;
3561
+ }
3562
+ //#endregion
3563
+ //#region src/realtime/updates.ts
3564
+ /** Типы нормализованных обновлений потока. */
3565
+ const RealtimeUpdateType = Object.freeze({
3566
+ Notification: "notification",
3567
+ UnreadCount: "unreadCount",
3568
+ Unknown: "unknown"
3569
+ });
3570
+ /** Источники нормализованных обновлений потока. */
3571
+ const RealtimeUpdateOrigin = Object.freeze({
3572
+ Stream: "stream",
3573
+ Sync: "sync"
3574
+ });
3575
+ /** Проверяет форму фильтра уведомлений. */
3576
+ function validateNotificationSelector(selector) {
3577
+ if (typeof selector === "string") {
3578
+ if (selector.length === 0) throw new require_storage.ItdConfigError("Тип уведомления не должен быть пустым");
3579
+ return;
3580
+ }
3581
+ if (Array.isArray(selector)) {
3582
+ if (selector.length === 0 || selector.some((type) => typeof type !== "string" || !type)) throw new require_storage.ItdConfigError("Список типов уведомлений должен содержать непустые строки");
3583
+ return;
3584
+ }
3585
+ if (typeof selector !== "object" || selector === null) throw new require_storage.ItdConfigError("Фильтр уведомлений должен быть строкой, списком или объектом");
3586
+ const filter = selector;
3587
+ if (filter.type !== void 0) validateNotificationSelector(filter.type);
3588
+ if (filter.actorId !== void 0 && typeof filter.actorId !== "string") throw new require_storage.ItdConfigError("Фильтр уведомлений: actorId должен быть строкой");
3589
+ for (const field of ["entityId", "parentEntityId"]) {
3590
+ const value = filter[field];
3591
+ if (value !== void 0 && value !== null && typeof value !== "string") throw new require_storage.ItdConfigError(`Фильтр уведомлений: ${field} должен быть строкой или null`);
3592
+ }
3593
+ if (filter.predicate !== void 0 && typeof filter.predicate !== "function") throw new require_storage.ItdConfigError("Фильтр уведомлений: predicate должен быть функцией");
3594
+ }
3595
+ /** Преобразует транспортный кадр в одно логическое обновление. */
3596
+ function readRealtimeUpdate(event) {
3597
+ if (event.name === "notification") return {
3598
+ type: RealtimeUpdateType.Notification,
3599
+ data: readNotificationEvent(event.data)
3600
+ };
3601
+ if (event.name === "unread_count") {
3602
+ const count = readUnreadCountEvent(event.data);
3603
+ return count === void 0 ? void 0 : {
3604
+ type: RealtimeUpdateType.UnreadCount,
3605
+ data: count
3606
+ };
3607
+ }
3608
+ return {
3609
+ type: RealtimeUpdateType.Unknown,
3610
+ name: event.name,
3611
+ data: event.data
3612
+ };
3613
+ }
3614
+ /** Проверяет объектный или краткий фильтр уведомления. */
3615
+ function matchesNotification(context, selector) {
3616
+ const notification = context.update.data.notification;
3617
+ if (typeof selector === "string") return notification.type === selector;
3618
+ if (Array.isArray(selector)) return selector.includes(notification.type);
3619
+ const filter = selector;
3620
+ const types = filter.type === void 0 ? void 0 : [filter.type].flat();
3621
+ if (types && !types.includes(notification.type)) return false;
3622
+ if (filter.actorId !== void 0 && !notification.actors.some(({ id }) => id === filter.actorId)) return false;
3623
+ if (filter.entityId !== void 0 && notification.entityId !== filter.entityId) return false;
3624
+ if (filter.parentEntityId !== void 0 && notification.parentEntityId !== filter.parentEntityId) return false;
3625
+ return filter.predicate?.(context) ?? true;
3626
+ }
3627
+ /** Сужает произвольный контекст потока до контекста уведомления. */
3628
+ function isNotificationContext(context) {
3629
+ return context.update.type === RealtimeUpdateType.Notification;
3630
+ }
3631
+ //#endregion
3380
3632
  //#region src/realtime/stream.ts
3381
3633
  /** Способ получения событий. */
3382
3634
  const RealtimeTransportKind = Object.freeze({
@@ -3398,6 +3650,8 @@ function validateRealtimeOptions(options) {
3398
3650
  if (!Number.isFinite(value) || value < min) throw new require_storage.ItdConfigError(`realtime.${name} должен быть числом не меньше ${min}, получено: ${value}`);
3399
3651
  };
3400
3652
  positiveInteger(options.maxAttempts, "maxAttempts");
3653
+ positiveInteger(options.concurrency, "concurrency");
3654
+ if (options.concurrency === 0) throw new require_storage.ItdConfigError("realtime.concurrency должен быть больше нуля");
3401
3655
  duration(options.pollInterval, "pollInterval", 1);
3402
3656
  duration(options.idleTimeout, "idleTimeout", 0);
3403
3657
  duration(options.handshakeTimeout, "handshakeTimeout", 0);
@@ -3406,6 +3660,7 @@ function validateRealtimeOptions(options) {
3406
3660
  if (!Array.isArray(options.backoff) || options.backoff.length === 0) throw new require_storage.ItdConfigError("realtime.backoff должен быть непустым списком пауз");
3407
3661
  for (const delay of options.backoff) duration(delay, "backoff", 0);
3408
3662
  }
3663
+ if (options.sequentialize !== void 0 && typeof options.sequentialize !== "function") throw new require_storage.ItdConfigError("realtime.sequentialize должен быть функцией");
3409
3664
  }
3410
3665
  /**
3411
3666
  * Поток уведомлений в реальном времени.
@@ -3415,22 +3670,26 @@ function validateRealtimeOptions(options) {
3415
3670
  *
3416
3671
  * @example
3417
3672
  * ```ts
3673
+ * import { NotificationType } from 'itd-api';
3674
+ *
3418
3675
  * const stream = itd.realtime();
3419
3676
  *
3420
- * stream.on('notification', ({ notification, unreadCount }) => {
3421
- * console.log(formatNotificationText(notification), unreadCount);
3677
+ * stream.onNotification(NotificationType.PostComment, async ({ update }) => {
3678
+ * await saveCommentNotification(update.data.notification);
3422
3679
  * });
3423
3680
  * stream.on('status', (status) => console.log('соединение:', status));
3424
3681
  *
3425
3682
  * await stream.connect();
3426
3683
  * // …позже
3427
3684
  * stream.disconnect();
3685
+ * await stream.drain();
3428
3686
  * ```
3429
3687
  */
3430
3688
  var ItdRealtime = class {
3431
3689
  #deps;
3432
3690
  #options;
3433
3691
  #emitter;
3692
+ #dispatcher;
3434
3693
  #transport;
3435
3694
  #maxAttempts;
3436
3695
  #controller;
@@ -3445,7 +3704,7 @@ var ItdRealtime = class {
3445
3704
  #wanted = false;
3446
3705
  #status = RealtimeStatus.Disconnected;
3447
3706
  #attempt = 0;
3448
- #timer;
3707
+ #cancelTimer;
3449
3708
  #detachEnvironment;
3450
3709
  constructor(deps, options = {}) {
3451
3710
  validateRealtimeOptions(options);
@@ -3458,6 +3717,14 @@ var ItdRealtime = class {
3458
3717
  if (deps.logger) deps.logger.error(message, error);
3459
3718
  else console.error(`[itd-api] ${message}`, error);
3460
3719
  });
3720
+ this.#dispatcher = new RealtimeDispatcher({
3721
+ concurrency: options.concurrency ?? 1,
3722
+ ...options.sequentialize ? { sequentialize: options.sequentialize } : {}
3723
+ }, {
3724
+ deliver: (context) => this.#deliver(context.update),
3725
+ middlewareError: (error, context) => this.#reportDispatchError("middlewareError", error, context),
3726
+ handlerError: (error, context) => this.#reportDispatchError("handlerError", error, context)
3727
+ });
3461
3728
  }
3462
3729
  /** Текущее состояние соединения. */
3463
3730
  get status() {
@@ -3488,6 +3755,40 @@ var ItdRealtime = class {
3488
3755
  return this.#emitter.once(event, listener);
3489
3756
  }
3490
3757
  /**
3758
+ * Добавляет промежуточный обработчик нормализованных обновлений.
3759
+ *
3760
+ * Обработчики выполняются в порядке регистрации. Если `next()` не вызван, обновление не
3761
+ * передаётся дальше по цепочке, асинхронным обработчикам и слушателям событий.
3762
+ *
3763
+ * @returns функция удаления обработчика
3764
+ */
3765
+ use(middleware) {
3766
+ if (typeof middleware !== "function") throw new require_storage.ItdConfigError("realtime.use() принимает функцию обработки");
3767
+ return this.#dispatcher.use(middleware);
3768
+ }
3769
+ onUpdate(selectorOrHandler, selectedHandler) {
3770
+ const selectAll = selectedHandler === void 0;
3771
+ const handler = selectAll ? selectorOrHandler : selectedHandler;
3772
+ if (typeof handler !== "function") throw new require_storage.ItdConfigError("realtime.onUpdate() принимает функцию обработчика");
3773
+ if (!selectAll && typeof selectorOrHandler !== "function" && !Object.values(RealtimeUpdateType).includes(selectorOrHandler)) throw new require_storage.ItdConfigError(`Неизвестный тип обновления потока: ${String(selectorOrHandler)}`);
3774
+ let predicate;
3775
+ if (selectAll) predicate = () => true;
3776
+ else {
3777
+ const selector = selectorOrHandler;
3778
+ predicate = typeof selector === "function" ? selector : (context) => context.update.type === selector;
3779
+ }
3780
+ return this.#dispatcher.on(predicate, handler);
3781
+ }
3782
+ onNotification(selector, handler) {
3783
+ if (typeof handler !== "function") throw new require_storage.ItdConfigError("realtime.onNotification() принимает функцию обработчика");
3784
+ if (typeof selector !== "function") validateNotificationSelector(selector);
3785
+ const predicate = (context) => {
3786
+ if (!isNotificationContext(context)) return false;
3787
+ return typeof selector === "function" ? selector(context) : matchesNotification(context, selector);
3788
+ };
3789
+ return this.#dispatcher.on(predicate, handler);
3790
+ }
3791
+ /**
3491
3792
  * Поднимает соединение.
3492
3793
  *
3493
3794
  * Повторный вызов при уже живом соединении ничего не делает — это защита от двойного
@@ -3498,9 +3799,14 @@ var ItdRealtime = class {
3498
3799
  async connect() {
3499
3800
  if (this.#wanted) return;
3500
3801
  this.#wanted = true;
3802
+ this.#deps.onConnect?.();
3501
3803
  this.#attachEnvironmentListeners();
3502
3804
  if (this.#options.syncCount !== false) try {
3503
- this.#emitter.emit("unreadCount", await this.#deps.fetchUnreadCount());
3805
+ const count = await this.#deps.fetchUnreadCount();
3806
+ if (this.#wanted) this.#dispatch({
3807
+ type: RealtimeUpdateType.UnreadCount,
3808
+ data: count
3809
+ }, void 0, RealtimeUpdateOrigin.Sync);
3504
3810
  } catch (error) {
3505
3811
  this.#deps.logger?.debug("не удалось получить число непрочитанных", error);
3506
3812
  }
@@ -3509,27 +3815,36 @@ var ItdRealtime = class {
3509
3815
  /** Закрывает соединение и отменяет запланированные попытки. */
3510
3816
  disconnect() {
3511
3817
  this.#wanted = false;
3512
- if (this.#timer !== void 0) {
3513
- clearTimeout(this.#timer);
3514
- this.#timer = void 0;
3818
+ if (this.#cancelTimer) {
3819
+ this.#cancelTimer();
3820
+ this.#cancelTimer = void 0;
3515
3821
  }
3516
3822
  this.#detachEnvironment?.();
3517
3823
  this.#detachEnvironment = void 0;
3518
3824
  this.#controller?.abort();
3519
3825
  this.#controller = void 0;
3520
3826
  this.#attempt = 0;
3827
+ this.#dispatcher.clearPending();
3521
3828
  this.#setStatus(RealtimeStatus.Disconnected);
3522
3829
  this.#deps.onClose?.();
3523
3830
  }
3524
- /** Снимает все подписки. Соединение при этом не закрывается. */
3831
+ /** Ждёт завершения всех принятых обновлений. */
3832
+ drain() {
3833
+ return this.#dispatcher.drain();
3834
+ }
3835
+ /** Снимает подписки `on()` и `once()`. Остальные обработчики остаются. */
3525
3836
  removeAllListeners() {
3526
3837
  this.#emitter.removeAllListeners();
3527
3838
  }
3528
3839
  #createTransport() {
3529
3840
  const kind = this.#options.transport ?? RealtimeTransportKind.Auto;
3530
3841
  if (typeof kind === "object") return kind;
3531
- if (kind === RealtimeTransportKind.Poll || kind === RealtimeTransportKind.Auto && !require_runtime.supportsStreamingBody()) return new PollTransport({ ...this.#options.pollInterval !== void 0 ? { interval: this.#options.pollInterval } : {} });
3842
+ if (kind === RealtimeTransportKind.Poll || kind === RealtimeTransportKind.Auto && !require_runtime.supportsStreamingBody()) return new PollTransport({
3843
+ clock: this.#deps.clock ?? systemClock,
3844
+ ...this.#options.pollInterval !== void 0 ? { interval: this.#options.pollInterval } : {}
3845
+ });
3532
3846
  return new SseTransport({
3847
+ clock: this.#deps.clock ?? systemClock,
3533
3848
  ...this.#options.idleTimeout !== void 0 ? { idleTimeout: this.#options.idleTimeout } : {},
3534
3849
  ...this.#options.handshakeTimeout !== void 0 ? { handshakeTimeout: this.#options.handshakeTimeout } : {}
3535
3850
  });
@@ -3551,7 +3866,7 @@ var ItdRealtime = class {
3551
3866
  this.#attempt = 0;
3552
3867
  this.#setStatus(RealtimeStatus.Connected);
3553
3868
  },
3554
- onEvent: (event) => this.#handleEvent(event.name, event.data),
3869
+ onEvent: (event) => this.#handleEvent(event),
3555
3870
  onParseError: (error, raw) => this.#emitter.emit("parseError", {
3556
3871
  error,
3557
3872
  raw
@@ -3563,25 +3878,48 @@ var ItdRealtime = class {
3563
3878
  this.#handleFailure(error);
3564
3879
  });
3565
3880
  }
3566
- #handleEvent(name, data) {
3567
- this.#emitter.emit("message", {
3568
- name,
3569
- data
3881
+ #handleEvent(event) {
3882
+ if (!this.#wanted) return;
3883
+ this.#emitter.emit("message", event);
3884
+ if (event.name === "connected") {
3885
+ this.#emitter.emit("ready", { userId: pickString(event.data, "userId") });
3886
+ return;
3887
+ }
3888
+ const update = readRealtimeUpdate(event);
3889
+ if (update) this.#dispatch(update, event, RealtimeUpdateOrigin.Stream);
3890
+ }
3891
+ #dispatch(update, raw, origin) {
3892
+ this.#dispatcher.dispatch({
3893
+ update,
3894
+ stream: this,
3895
+ raw,
3896
+ origin
3570
3897
  });
3571
- if (name === "connected") {
3572
- this.#emitter.emit("ready", { userId: pickString(data, "userId") });
3898
+ }
3899
+ #deliver(update) {
3900
+ if (update.type === RealtimeUpdateType.Notification) {
3901
+ this.#emitter.emit("notification", update.data);
3902
+ if (update.data.unreadCount !== void 0) this.#emitter.emit("unreadCount", update.data.unreadCount);
3573
3903
  return;
3574
3904
  }
3575
- if (name === "notification") {
3576
- const event = readNotificationEvent(data);
3577
- this.#emitter.emit("notification", event);
3578
- if (event.unreadCount !== void 0) this.#emitter.emit("unreadCount", event.unreadCount);
3905
+ if (update.type === RealtimeUpdateType.UnreadCount) {
3906
+ this.#emitter.emit("unreadCount", update.data);
3579
3907
  return;
3580
3908
  }
3581
- if (name === "unread_count") {
3582
- const count = readUnreadCountEvent(data);
3583
- if (count !== void 0) this.#emitter.emit("unreadCount", count);
3909
+ if (update.type === RealtimeUpdateType.Unknown) return;
3910
+ assertNeverUpdate(update);
3911
+ }
3912
+ #reportDispatchError(event, error, context) {
3913
+ if (this.#emitter.listenerCount(event) > 0) {
3914
+ this.#emitter.emit(event, {
3915
+ error,
3916
+ context
3917
+ });
3918
+ return;
3584
3919
  }
3920
+ const message = event === "middlewareError" ? "Ошибка в промежуточном обработчике потока" : "Ошибка в обработчике обновления потока";
3921
+ if (this.#deps.logger) this.#deps.logger.error(message, error);
3922
+ else console.error(`[itd-api] ${message}`, error);
3585
3923
  }
3586
3924
  #handleFailure(error) {
3587
3925
  this.#controller = void 0;
@@ -3619,8 +3957,8 @@ var ItdRealtime = class {
3619
3957
  attempt: this.#attempt,
3620
3958
  delay
3621
3959
  });
3622
- this.#timer = setTimeout(() => {
3623
- this.#timer = void 0;
3960
+ this.#cancelTimer = (this.#deps.clock ?? systemClock).schedule(() => {
3961
+ this.#cancelTimer = void 0;
3624
3962
  this.#run();
3625
3963
  }, delay);
3626
3964
  }
@@ -3647,7 +3985,7 @@ var ItdRealtime = class {
3647
3985
  const target = globalThis;
3648
3986
  if (typeof target.addEventListener !== "function") return;
3649
3987
  const wake = () => {
3650
- if (this.#controller || this.#timer !== void 0) return;
3988
+ if (this.#controller || this.#cancelTimer) return;
3651
3989
  if (this.#status === RealtimeStatus.Disconnected) return;
3652
3990
  this.#attempt = 0;
3653
3991
  this.#run();
@@ -3669,6 +4007,9 @@ var ItdRealtime = class {
3669
4007
  this.#emitter.emit("status", status);
3670
4008
  }
3671
4009
  };
4010
+ function assertNeverUpdate(update) {
4011
+ throw new TypeError(`Необработанное обновление потока: ${String(update)}`);
4012
+ }
3672
4013
  //#endregion
3673
4014
  //#region src/core/pagination.ts
3674
4015
  /** Схема пагинации эндпоинта. */
@@ -7573,7 +7914,7 @@ var ItdClient = class ItdClient {
7573
7914
  for (const service of BUILT_IN_SERVICES) this.#services.define(service);
7574
7915
  for (const service of config.services) this.#services.define(service);
7575
7916
  const shared = config.rateLimit ? internals.queues : void 0;
7576
- const queues = shared ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit) : void 0);
7917
+ const queues = shared ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit, config.clock) : void 0);
7577
7918
  this.#queues = queues;
7578
7919
  this.#ownsQueues = shared === void 0;
7579
7920
  let authManager;
@@ -7589,6 +7930,7 @@ var ItdClient = class ItdClient {
7589
7930
  this.#transport = transport;
7590
7931
  const pluginsLayer = createPluginsMiddleware(this.#plugins);
7591
7932
  const retriesLayer = createRetryMiddleware({
7933
+ clock: config.clock,
7592
7934
  retry: config.retry,
7593
7935
  rateLimitDelays: config.rateLimit?.retryDelays ?? [],
7594
7936
  pauseQueue: queues ? (ms, request) => queues.for(request.service).pause(ms) : void 0,
@@ -7768,10 +8110,12 @@ var ItdClient = class ItdClient {
7768
8110
  *
7769
8111
  * @example
7770
8112
  * ```ts
8113
+ * import { NotificationType } from 'itd-api';
8114
+ *
7771
8115
  * const stream = itd.realtime();
7772
8116
  *
7773
- * stream.on('notification', ({ notification }) => {
7774
- * console.log(formatNotificationText(notification));
8117
+ * stream.onNotification(NotificationType.PostComment, async ({ update }) => {
8118
+ * await handleComment(update.data.notification);
7775
8119
  * });
7776
8120
  * stream.on('unreadCount', (count) => setBadge(count));
7777
8121
  *
@@ -7789,8 +8133,10 @@ var ItdClient = class ItdClient {
7789
8133
  getToken: () => this.#authManager.getAccessToken(),
7790
8134
  refresh: () => this.#authManager.onUnauthorized(),
7791
8135
  fetchUnreadCount: () => this.notifications.count(),
8136
+ onConnect: () => this.#streams.add(stream),
7792
8137
  onClose: () => this.#streams.delete(stream),
7793
- logger: this.#config.logger
8138
+ logger: this.#config.logger,
8139
+ clock: this.#config.clock
7794
8140
  }, options);
7795
8141
  this.#streams.add(stream);
7796
8142
  return stream;
@@ -7799,8 +8145,8 @@ var ItdClient = class ItdClient {
7799
8145
  * Освобождает ресурсы клиента: закрывает все потоки уведомлений, отправляет открытые
7800
8146
  * накопители {@link telemetry}, затем останавливает очередь запросов.
7801
8147
  *
7802
- * После вызова клиентом можно пользоваться снова — новые запросы поднимут всё заново,
7803
- * но уже созданные потоки и успешно закрытые накопители останутся закрытыми.
8148
+ * Метод дожидается активных обработчиков потока. После вызова клиентом можно пользоваться
8149
+ * снова; ранее созданный поток можно запустить повторным `connect()`.
7804
8150
  *
7805
8151
  * Общая очередь, полученная от {@link ItdAccounts}, не останавливается: её гасит сам
7806
8152
  * контейнер, когда закрывает все аккаунты разом.
@@ -7813,8 +8159,9 @@ var ItdClient = class ItdClient {
7813
8159
  * ```
7814
8160
  */
7815
8161
  async close() {
7816
- this.#disconnectStreams();
8162
+ const streams = this.#disconnectStreams();
7817
8163
  try {
8164
+ await Promise.all(streams.map((stream) => stream.drain()));
7818
8165
  await this.telemetry.close();
7819
8166
  } finally {
7820
8167
  if (this.#ownsQueues) this.#queues?.stop();
@@ -7833,8 +8180,10 @@ var ItdClient = class ItdClient {
7833
8180
  }
7834
8181
  /** Завершает потоки до того, как запросы начнут использовать другой аккаунт. */
7835
8182
  #disconnectStreams() {
7836
- for (const stream of [...this.#streams]) stream.disconnect();
8183
+ const streams = [...this.#streams];
8184
+ for (const stream of streams) stream.disconnect();
7837
8185
  this.#streams.clear();
8186
+ return streams;
7838
8187
  }
7839
8188
  /** Позволяет использовать клиент с `await using`. */
7840
8189
  [Symbol.asyncDispose]() {
@@ -7973,7 +8322,7 @@ var ItdAccounts = class ItdAccounts {
7973
8322
  this.#plugins = orderPluginDefinitions(plugins ?? []);
7974
8323
  this.#rateLimitScope = rateLimitScope ?? "account";
7975
8324
  const rateLimit = this.#rateLimitScope === "shared" ? resolveRateLimit(base.rateLimit) : void 0;
7976
- this.#queues = rateLimit ? new RequestQueuePool(rateLimit) : void 0;
8325
+ this.#queues = rateLimit ? new RequestQueuePool(rateLimit, base.clock ?? systemClock) : void 0;
7977
8326
  const logger = typeof base.logger === "object" ? base.logger : void 0;
7978
8327
  this.#logger = logger;
7979
8328
  this.#emitter = new Emitter((error) => reportListenerError(logger, "аккаунтов", error));
@@ -8433,6 +8782,87 @@ function resolveNotificationUrl(notification) {
8433
8782
  return clickUrl || "/notifications";
8434
8783
  }
8435
8784
  //#endregion
8785
+ //#region src/realtime/router.ts
8786
+ /**
8787
+ * Направляет обновления потока в именованные цепочки промежуточных обработчиков.
8788
+ *
8789
+ * @example
8790
+ * ```ts
8791
+ * import { RealtimeRouter, RealtimeUpdateType } from 'itd-api';
8792
+ *
8793
+ * const router = new RealtimeRouter((context) => context.update.type);
8794
+ * router.route(RealtimeUpdateType.Notification, async (context, next) => {
8795
+ * if (context.update.type === RealtimeUpdateType.Notification) {
8796
+ * await handleNotification(context.update.data.notification);
8797
+ * }
8798
+ * await next();
8799
+ * });
8800
+ * stream.use(router.middleware());
8801
+ * ```
8802
+ */
8803
+ var RealtimeRouter = class {
8804
+ #selector;
8805
+ #routes = /* @__PURE__ */ new Map();
8806
+ #fallback = [];
8807
+ constructor(selector) {
8808
+ if (typeof selector !== "function") throw new require_storage.ItdConfigError("RealtimeRouter принимает функцию выбора маршрута");
8809
+ this.#selector = selector;
8810
+ }
8811
+ /** Добавляет промежуточные обработчики к маршруту и возвращает функцию их удаления. */
8812
+ route(key, ...middleware) {
8813
+ if (!isPropertyKey(key)) throw new require_storage.ItdConfigError("Ключ realtime route должен быть PropertyKey");
8814
+ const registration = this.#registration(middleware);
8815
+ const registrations = this.#routes.get(key) ?? [];
8816
+ registrations.push(registration);
8817
+ this.#routes.set(key, registrations);
8818
+ return () => {
8819
+ const current = this.#routes.get(key);
8820
+ if (!current) return;
8821
+ const index = current.indexOf(registration);
8822
+ if (index >= 0) current.splice(index, 1);
8823
+ if (current.length === 0) this.#routes.delete(key);
8824
+ };
8825
+ }
8826
+ /** Добавляет промежуточные обработчики для обновлений без зарегистрированного маршрута. */
8827
+ otherwise(...middleware) {
8828
+ const registration = this.#registration(middleware);
8829
+ this.#fallback.push(registration);
8830
+ return () => {
8831
+ const index = this.#fallback.indexOf(registration);
8832
+ if (index >= 0) this.#fallback.splice(index, 1);
8833
+ };
8834
+ }
8835
+ /** Возвращает промежуточный обработчик для `stream.use()`. */
8836
+ middleware() {
8837
+ const middleware = (context, next) => this.#captureMiddleware()(context, next);
8838
+ return withRealtimeMiddlewareSnapshot(middleware, () => this.#captureMiddleware());
8839
+ }
8840
+ #captureMiddleware() {
8841
+ const routes = /* @__PURE__ */ new Map();
8842
+ for (const [key, registrations] of this.#routes) routes.set(key, registrations.flatMap(({ middleware }) => middleware).map(captureRealtimeMiddleware));
8843
+ const fallback = this.#fallback.flatMap(({ middleware }) => middleware).map(captureRealtimeMiddleware);
8844
+ return async (context, next) => {
8845
+ const key = await this.#selector(context);
8846
+ if (key != null && !isPropertyKey(key)) throw new require_storage.ItdConfigError("Функция выбора маршрута должна возвращать PropertyKey, null или undefined");
8847
+ const route = key == null ? void 0 : routes.get(key);
8848
+ const chain = route && route.length > 0 ? route : fallback;
8849
+ if (chain.length === 0) {
8850
+ await next();
8851
+ return;
8852
+ }
8853
+ await runRealtimeMiddleware(chain, context, next);
8854
+ };
8855
+ }
8856
+ #registration(middleware) {
8857
+ if (middleware.length === 0) throw new require_storage.ItdConfigError("Маршрут должен содержать хотя бы один обработчик");
8858
+ for (const item of middleware) if (typeof item !== "function") throw new require_storage.ItdConfigError("Маршрут принимает только функции обработки");
8859
+ return { middleware: [...middleware] };
8860
+ }
8861
+ };
8862
+ function isPropertyKey(value) {
8863
+ return typeof value === "string" || typeof value === "number" || typeof value === "symbol";
8864
+ }
8865
+ //#endregion
8436
8866
  //#region src/spans/render.ts
8437
8867
  /** Формат результата {@link renderSpans}. */
8438
8868
  const SpanRenderFormat = Object.freeze({
@@ -8743,8 +9173,11 @@ exports.RECONNECT_JITTER = RECONNECT_JITTER;
8743
9173
  exports.REFRESH_COOKIE = require_multi_storage.REFRESH_COOKIE;
8744
9174
  exports.REFRESH_COOKIE_PATH = require_multi_storage.REFRESH_COOKIE_PATH;
8745
9175
  exports.REQUEST_OPTION_KEYS = REQUEST_OPTION_KEYS;
9176
+ exports.RealtimeRouter = RealtimeRouter;
8746
9177
  exports.RealtimeStatus = RealtimeStatus;
8747
9178
  exports.RealtimeTransportKind = RealtimeTransportKind;
9179
+ exports.RealtimeUpdateOrigin = RealtimeUpdateOrigin;
9180
+ exports.RealtimeUpdateType = RealtimeUpdateType;
8748
9181
  exports.ReportReason = ReportReason;
8749
9182
  exports.ReportTargetType = ReportTargetType;
8750
9183
  exports.RuntimeMode = require_runtime.RuntimeMode;
@@ -8800,6 +9233,7 @@ exports.report = report;
8800
9233
  exports.resolveNotificationUrl = resolveNotificationUrl;
8801
9234
  exports.scopedTokenStorage = require_multi_storage.scopedTokenStorage;
8802
9235
  exports.statusDays = statusDays;
9236
+ exports.systemClock = systemClock;
8803
9237
  exports.toDate = toDate;
8804
9238
  exports.utcStampToIso = utcStampToIso;
8805
9239