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