itd-api 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_multi_storage = require("./multi-storage-D1keK2Op.cjs");
2
+ const require_multi_storage = require("./multi-storage-C8R5Crwo.cjs");
3
3
  const require_storage = require("./storage-ycBqLBRB.cjs");
4
4
  const require_runtime = require("./runtime-CFEsf-jD.cjs");
5
5
  //#region src/core/emitter.ts
@@ -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.4.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),
@@ -1040,15 +1052,271 @@ function withLayerHeaders(request, headers) {
1040
1052
  };
1041
1053
  }
1042
1054
  //#endregion
1043
- //#region src/core/plugins.ts
1044
- /** Пустой набор отдаётся, пока плагинов нет, чтобы не заводить объект на каждый запрос. */
1045
- const NO_KEYS = /* @__PURE__ */ new Set();
1055
+ //#region src/core/plugins/hooks.ts
1056
+ const REQUEST_HOOK_DISPATCHERS = /* @__PURE__ */ new WeakMap();
1057
+ const PLUGIN_HOOK_SCOPE = Symbol("itd-api.plugin-hooks");
1058
+ /** Создаёт динамический набор hooks, связанный с диспетчером registry. @internal */
1059
+ function createRequestHooks(dispatcher) {
1060
+ const hooks = {};
1061
+ REQUEST_HOOK_DISPATCHERS.set(hooks, dispatcher);
1062
+ return hooks;
1063
+ }
1064
+ /** Привязывает к запросу неизменяемый снимок plugin hooks. @internal */
1065
+ function withRequestHookScope(request, hooks) {
1066
+ const scoped = request;
1067
+ return scoped[PLUGIN_HOOK_SCOPE] === hooks ? scoped : {
1068
+ ...request,
1069
+ [PLUGIN_HOOK_SCOPE]: hooks
1070
+ };
1071
+ }
1072
+ /** Читает снимок plugin hooks, привязанный к логическому запросу. @internal */
1073
+ function requestHookScope(request) {
1074
+ return request[PLUGIN_HOOK_SCOPE] ?? [];
1075
+ }
1076
+ /**
1077
+ * Вызывает публичный хук, сохраняя привязанный к логическому запросу снимок плагинов.
1078
+ *
1079
+ * Обычные наборы хуков по-прежнему получают только публичный контекст. Дополнительный
1080
+ * аргумент используется исключительно внутренним составным набором PluginRegistry.
1081
+ *
1082
+ * @internal
1083
+ */
1084
+ async function dispatchRequestHook(hooks, field, context, request) {
1085
+ const dispatcher = REQUEST_HOOK_DISPATCHERS.get(hooks);
1086
+ if (dispatcher) {
1087
+ await dispatcher(field, context, request);
1088
+ return;
1089
+ }
1090
+ const hook = hooks[field];
1091
+ await hook?.(context);
1092
+ }
1093
+ //#endregion
1094
+ //#region src/core/retry.ts
1095
+ /**
1096
+ * Методы, повтор которых безопасен по определению.
1097
+ *
1098
+ * `DELETE` формально тоже идемпотентен, но его повтор после успеха вернёт `404`
1099
+ * и собьёт с толку — в список он не входит.
1100
+ */
1101
+ const IDEMPOTENT_METHODS = /* @__PURE__ */ new Set([
1102
+ "GET",
1103
+ "HEAD",
1104
+ "OPTIONS"
1105
+ ]);
1106
+ /**
1107
+ * Стоит ли повторять запрос после этой ошибки.
1108
+ *
1109
+ * Отдельно разобран `429`: он повторяется даже для запросов на запись, потому что
1110
+ * гарантирует, что запрос **не был обработан**. Обрыв сети и `5xx` такой гарантии не дают —
1111
+ * сервер мог успеть создать пост, — поэтому запись по умолчанию не повторяется.
1112
+ */
1113
+ function isRetryable(error, method, retryWrites, retryNetworkWrite) {
1114
+ if (error instanceof require_storage.ItdAbortError) return false;
1115
+ const safeToRepeat = retryWrites || IDEMPOTENT_METHODS.has(method);
1116
+ if (error instanceof require_storage.ItdApiError) {
1117
+ if (error.status === 429) return true;
1118
+ if (error.status >= 500) return safeToRepeat;
1119
+ return false;
1120
+ }
1121
+ if (error instanceof require_storage.ItdNetworkError || error instanceof require_storage.ItdTimeoutError) return safeToRepeat || retryNetworkWrite;
1122
+ if (error instanceof require_storage.ItdFileError) return error.retryable && (safeToRepeat || retryNetworkWrite);
1123
+ return false;
1124
+ }
1125
+ /** Экспоненциальная пауза со случайным разбросом. */
1126
+ function backoffDelay(attempt, options, random) {
1127
+ const exponential = options.baseDelay * 2 ** (attempt - 1);
1128
+ const capped = Math.min(exponential, options.maxDelay);
1129
+ const spread = capped * options.jitter * (random() * 2 - 1);
1130
+ return Math.max(0, Math.round(capped + spread));
1131
+ }
1132
+ /**
1133
+ * Собирает планировщик повторов для транспорта.
1134
+ *
1135
+ * Поведение при `Retry-After`: пауза, названная сервером, соблюдается точно — она
1136
+ * авторитетнее нашего расчёта. Но если сервер просит ждать дольше, чем `maxDelay`,
1137
+ * повтор **не выполняется вовсе**: молча спать десять минут внутри вызова библиотека
1138
+ * не должна, лучше отдать {@link ItdRateLimitError} и дать решить вызывающему коду.
1139
+ *
1140
+ * @param options настройки повторов после подстановки значений по умолчанию
1141
+ * @param random источник случайности; подменяется в тестах ради предсказуемости
1142
+ *
1143
+ * @example
1144
+ * ```ts
1145
+ * const scheduler = createRetryScheduler(config.retry);
1146
+ * const delay = scheduler(error, 1, 'GET'); // 500 мс ± 30%
1147
+ * ```
1148
+ */
1149
+ function createRetryScheduler(options, random = Math.random) {
1150
+ return (error, attempt, method, retryNetworkWrite = false) => {
1151
+ if (attempt >= options.attempts) return void 0;
1152
+ if (options.shouldRetry) return options.shouldRetry(error, attempt) ? backoffDelay(attempt, options, random) : void 0;
1153
+ if (!isRetryable(error, method, options.retryWrites, retryNetworkWrite)) return void 0;
1154
+ if (error instanceof require_storage.ItdApiError && error.retryAfter !== void 0) return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
1155
+ return backoffDelay(attempt, options, random);
1156
+ };
1157
+ }
1158
+ //#endregion
1159
+ //#region src/core/middleware.ts
1160
+ /** Ожидание повтора, которое уважает отмену запроса. */
1161
+ function sleep(clock, ms, signal) {
1162
+ if (!signal) return new Promise((resolve) => clock.schedule(resolve, ms));
1163
+ if (signal.aborted) return Promise.reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1164
+ return new Promise((resolve, reject) => {
1165
+ const cancel = clock.schedule(() => {
1166
+ signal.removeEventListener("abort", onAbort);
1167
+ resolve();
1168
+ }, ms);
1169
+ const onAbort = () => {
1170
+ cancel();
1171
+ reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1172
+ };
1173
+ signal.addEventListener("abort", onAbort, { once: true });
1174
+ });
1175
+ }
1176
+ /**
1177
+ * Слой очереди: ограничение конкурентности и частоты.
1178
+ *
1179
+ * `skipQueue` пропускает запрос мимо очереди — так поступают служебные запросы, которые
1180
+ * порождены изнутри другого запроса и не могут ждать освободившегося слота.
1181
+ *
1182
+ * Очередь выбирается по запросу: у каждого сервиса платформы свой хост и свой лимит.
1183
+ */
1184
+ function createQueueMiddleware(schedule) {
1185
+ return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
1186
+ }
1187
+ /**
1188
+ * Слой плагинов.
1189
+ *
1190
+ * Стоит снаружи повторов и внутри очереди: плагин должен увидеть запрос и ответ по одному
1191
+ * разу, независимо от числа попыток, — иначе, например, текст поста зашифруется дважды.
1192
+ */
1193
+ function createPluginsMiddleware(plugins) {
1194
+ return (request, next) => plugins.run(request, next);
1195
+ }
1196
+ /**
1197
+ * Слой сервисов.
1198
+ *
1199
+ * Запросу с полем `service` подставляет хост сервиса, его заголовки и `skipAuth`, если
1200
+ * сервис объявлен публичным. Заданный у запроса `baseUrl` не трогает.
1201
+ *
1202
+ * Стоит снаружи повторов и авторизации, чтобы выставленный здесь `skipAuth` был ей виден.
1203
+ */
1204
+ function createServicesMiddleware(registry) {
1205
+ return async (request, next) => {
1206
+ const service = request.service === void 0 ? void 0 : registry.require(request.service);
1207
+ let prepared = request;
1208
+ if (request.baseUrl !== void 0) {
1209
+ const baseUrl = normalizeBaseUrl(request.baseUrl);
1210
+ if (baseUrl !== request.baseUrl) prepared = {
1211
+ ...prepared,
1212
+ baseUrl
1213
+ };
1214
+ if (!(service?.baseUrl === baseUrl ? service.auth !== false : registry.isPrimarySite(baseUrl)) && prepared.skipAuth === void 0) prepared = {
1215
+ ...prepared,
1216
+ skipAuth: true
1217
+ };
1218
+ }
1219
+ if (!service) return next(prepared);
1220
+ if (prepared.baseUrl === void 0) prepared = {
1221
+ ...prepared,
1222
+ baseUrl: service.baseUrl
1223
+ };
1224
+ if (service.headers) prepared = withLayerHeaders(prepared, service.headers);
1225
+ if (service.auth === false && prepared.skipAuth === void 0) prepared = {
1226
+ ...prepared,
1227
+ skipAuth: true
1228
+ };
1229
+ return next(prepared);
1230
+ };
1231
+ }
1232
+ async function applyAuth(request, deps) {
1233
+ if (request.skipAuth) return request;
1234
+ const headers = await deps.getAuthHeaders();
1235
+ return Object.keys(headers).length > 0 ? withLayerHeaders(request, headers) : request;
1236
+ }
1237
+ /**
1238
+ * Слой авторизации.
1239
+ *
1240
+ * Подставляет заголовок `Authorization` и обрабатывает `401`: обновляет токен и повторяет
1241
+ * запрос ровно один раз. Стоит внутри повторов, поэтому обычным попыткам он не виден —
1242
+ * они уже работают со свежим токеном.
1243
+ */
1244
+ function createAuthMiddleware(deps) {
1245
+ return async (request, next) => {
1246
+ const authorized = await applyAuth(request, deps);
1247
+ try {
1248
+ return await next(authorized);
1249
+ } catch (error) {
1250
+ if (request.skipAuthRefresh || !deps.autoRefresh || !require_storage.isItdApiError(error) || error.status !== 401) throw error;
1251
+ if (!await deps.onUnauthorized()) throw error;
1252
+ return next(await applyAuth({
1253
+ ...request,
1254
+ skipAuthRefresh: true
1255
+ }, deps));
1256
+ }
1257
+ };
1258
+ }
1259
+ /**
1260
+ * Выбирает планировщик отката для конкретного запроса.
1261
+ *
1262
+ * `retry` у запроса переопределяет глобальную настройку: `false` выключает повторы,
1263
+ * объект задаёт свои. Обработка `429` от этого не зависит — она общая.
1264
+ */
1265
+ function resolveBackoff(retry, global) {
1266
+ if (retry === void 0) return global;
1267
+ if (retry === false) return void 0;
1268
+ const resolved = resolveRetry(retry);
1269
+ return resolved ? createRetryScheduler(resolved) : void 0;
1270
+ }
1046
1271
  /**
1047
- * Имена, которые плагин заявить не может.
1272
+ * Слой повторов.
1048
1273
  *
1049
- * Заявленные опции ресурсы переносят в описание запроса поверх собранных полей, поэтому
1050
- * имя из {@link RawRequestOptions} подменило бы путь, тело или заголовки любого вызова.
1274
+ * Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
1275
+ * всей очереди; сетевые сбои и `5xx` экспоненциальным откатом. Настройка `retry`
1276
+ * у отдельного запроса имеет приоритет над глобальной.
1051
1277
  */
1278
+ function createRetryMiddleware(deps) {
1279
+ const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
1280
+ const nextDelay = (error, attempt, request, method, backoff) => {
1281
+ if (require_storage.isItdRateLimitError(error)) {
1282
+ const wait = error.retryAfter ?? deps.rateLimitDelays[attempt - 1];
1283
+ if (wait === void 0) return void 0;
1284
+ deps.pauseQueue?.(wait, request);
1285
+ deps.logger?.debug(`лимит частоты, попытка ${attempt + 1} через ${wait} мс`);
1286
+ return wait;
1287
+ }
1288
+ return backoff?.(error, attempt, method, request.retryNetworkWrite ?? false);
1289
+ };
1290
+ return async (request, next) => {
1291
+ const method = request.method.toUpperCase();
1292
+ const backoff = resolveBackoff(request.retry, globalScheduler);
1293
+ for (let attempt = 1;; attempt++) try {
1294
+ return await next({
1295
+ ...request,
1296
+ attempt
1297
+ });
1298
+ } catch (error) {
1299
+ const delay = nextDelay(error, attempt, request, method, backoff);
1300
+ if (delay === void 0) throw error;
1301
+ await dispatchRequestHook(deps.hooks, "onRetry", {
1302
+ method,
1303
+ path: request.path,
1304
+ url: deps.buildUrl(request),
1305
+ headers: new Headers({
1306
+ ...request.layerHeaders,
1307
+ ...request.headers
1308
+ }),
1309
+ attempt,
1310
+ error,
1311
+ delay
1312
+ }, request);
1313
+ deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${attempt + 1} через ${delay} мс`);
1314
+ await sleep(deps.clock ?? systemClock, delay, request.signal);
1315
+ }
1316
+ };
1317
+ }
1318
+ //#endregion
1319
+ //#region src/core/plugins/order.ts
1052
1320
  const RESERVED_OPTION_KEYS = /* @__PURE__ */ new Set([
1053
1321
  "signal",
1054
1322
  "timeout",
@@ -1089,7 +1357,8 @@ function validateNameList(plugin, field) {
1089
1357
  seen.add(value);
1090
1358
  }
1091
1359
  }
1092
- function validateHooks(plugin, hooks) {
1360
+ /** Проверяет набор hooks, переданный плагином. @internal */
1361
+ function validatePluginHooks(plugin, hooks) {
1093
1362
  if (typeof hooks !== "object" || hooks === null) throw new require_storage.ItdConfigError(`плагин «${plugin}» передал в useHooks() не объект`);
1094
1363
  for (const field of HOOK_FIELDS) if (hooks[field] !== void 0 && typeof hooks[field] !== "function") throw new require_storage.ItdConfigError(`плагин «${plugin}»: useHooks().${field} должен быть функцией`);
1095
1364
  }
@@ -1176,32 +1445,16 @@ function orderPluginDefinitions(plugins) {
1176
1445
  if (ordered.length !== entries.length) throw new require_storage.ItdConfigError(`циклический порядок плагинов: ${entries.filter(({ plugin }) => (indegree.get(plugin.name) ?? 0) > 0).map(({ plugin }) => plugin.name).join(" → ")}`);
1177
1446
  return ordered;
1178
1447
  }
1179
- /** Проверяет, можно ли удалить плагин, не нарушив обязательные зависимости. @internal */
1448
+ /** Проверяет, можно ли удалить плагин без нарушения обязательных зависимостей. @internal */
1180
1449
  function assertPluginRemovable(plugins, name) {
1181
1450
  const dependent = plugins.find((plugin) => plugin.requires?.includes(name));
1182
1451
  if (dependent) throw new require_storage.ItdConfigError(`нельзя отключить плагин «${name}»: от него зависит «${dependent.name}»`);
1183
1452
  }
1184
- const REQUEST_HOOK_DISPATCHERS = /* @__PURE__ */ new WeakMap();
1185
- const PLUGIN_HOOK_SCOPE = Symbol("itd-api.plugin-hooks");
1453
+ //#endregion
1454
+ //#region src/core/plugins/registry.ts
1455
+ const NO_KEYS = /* @__PURE__ */ new Set();
1186
1456
  /**
1187
- * Вызывает публичный хук, сохраняя привязанный к логическому запросу снимок плагинов.
1188
- *
1189
- * Обычные наборы хуков по-прежнему получают только публичный контекст. Дополнительный
1190
- * аргумент используется исключительно внутренним составным набором PluginRegistry.
1191
- *
1192
- * @internal
1193
- */
1194
- async function dispatchRequestHook(hooks, field, context, request) {
1195
- const dispatcher = REQUEST_HOOK_DISPATCHERS.get(hooks);
1196
- if (dispatcher) {
1197
- await dispatcher(field, context, request);
1198
- return;
1199
- }
1200
- const hook = hooks[field];
1201
- await hook?.(context);
1202
- }
1203
- /**
1204
- * Список подключённых плагинов и собранная из них цепочка обёрток.
1457
+ * Список подключённых плагинов и собранная из них цепочка обёрток.
1205
1458
  *
1206
1459
  * Живёт в клиенте, а работает в транспорте: {@link HttpClient} прогоняет через `run`
1207
1460
  * каждый запрос, если плагины есть.
@@ -1257,7 +1510,7 @@ var PluginRegistry = class {
1257
1510
  transformers.push(transformer);
1258
1511
  },
1259
1512
  useHooks: (value) => {
1260
- validateHooks(plugin.name, value);
1513
+ validatePluginHooks(plugin.name, value);
1261
1514
  hooks.push({ ...value });
1262
1515
  }
1263
1516
  });
@@ -1339,9 +1592,7 @@ var PluginRegistry = class {
1339
1592
  * со следующего логического запроса без пересоздания транспорта.
1340
1593
  */
1341
1594
  hooks(base) {
1342
- const hooks = {};
1343
- REQUEST_HOOK_DISPATCHERS.set(hooks, ((field, context, request) => this.#runHook(field, context, request, base)));
1344
- return hooks;
1595
+ return createRequestHooks((field, context, request) => this.#runHook(field, context, request, base));
1345
1596
  }
1346
1597
  /**
1347
1598
  * Прогоняет запрос через цепочку обёрток.
@@ -1355,10 +1606,7 @@ var PluginRegistry = class {
1355
1606
  const entries = [...this.#ordered];
1356
1607
  for (const entry of entries) entry.activeRequests += 1;
1357
1608
  const hookScope = entries.flatMap((entry) => entry.hooks);
1358
- const scoped = (current) => current[PLUGIN_HOOK_SCOPE] === hookScope ? current : {
1359
- ...current,
1360
- [PLUGIN_HOOK_SCOPE]: hookScope
1361
- };
1609
+ const scoped = (current) => withRequestHookScope(current, hookScope);
1362
1610
  const chain = entries.flatMap((entry) => entry.transformers).reduceRight((next, transformer) => (current) => transformer(scoped(current), (prepared) => next(scoped(prepared))), (current) => execute(scoped(current)));
1363
1611
  try {
1364
1612
  return await chain(scoped(request));
@@ -1392,239 +1640,13 @@ var PluginRegistry = class {
1392
1640
  async #runHook(field, context, request, base) {
1393
1641
  const baseHook = base[field];
1394
1642
  await baseHook?.(context);
1395
- const scope = request[PLUGIN_HOOK_SCOPE] ?? [];
1396
- for (const hooks of scope) {
1643
+ for (const hooks of requestHookScope(request)) {
1397
1644
  const hook = hooks[field];
1398
1645
  await hook?.(context);
1399
1646
  }
1400
1647
  }
1401
1648
  };
1402
1649
  //#endregion
1403
- //#region src/core/retry.ts
1404
- /**
1405
- * Методы, повтор которых безопасен по определению.
1406
- *
1407
- * `DELETE` формально тоже идемпотентен, но его повтор после успеха вернёт `404`
1408
- * и собьёт с толку — в список он не входит.
1409
- */
1410
- const IDEMPOTENT_METHODS = /* @__PURE__ */ new Set([
1411
- "GET",
1412
- "HEAD",
1413
- "OPTIONS"
1414
- ]);
1415
- /**
1416
- * Стоит ли повторять запрос после этой ошибки.
1417
- *
1418
- * Отдельно разобран `429`: он повторяется даже для запросов на запись, потому что
1419
- * гарантирует, что запрос **не был обработан**. Обрыв сети и `5xx` такой гарантии не дают —
1420
- * сервер мог успеть создать пост, — поэтому запись по умолчанию не повторяется.
1421
- */
1422
- function isRetryable(error, method, retryWrites, retryNetworkWrite) {
1423
- if (error instanceof require_storage.ItdAbortError) return false;
1424
- const safeToRepeat = retryWrites || IDEMPOTENT_METHODS.has(method);
1425
- if (error instanceof require_storage.ItdApiError) {
1426
- if (error.status === 429) return true;
1427
- if (error.status >= 500) return safeToRepeat;
1428
- return false;
1429
- }
1430
- if (error instanceof require_storage.ItdNetworkError || error instanceof require_storage.ItdTimeoutError) return safeToRepeat || retryNetworkWrite;
1431
- if (error instanceof require_storage.ItdFileError) return error.retryable && (safeToRepeat || retryNetworkWrite);
1432
- return false;
1433
- }
1434
- /** Экспоненциальная пауза со случайным разбросом. */
1435
- function backoffDelay(attempt, options, random) {
1436
- const exponential = options.baseDelay * 2 ** (attempt - 1);
1437
- const capped = Math.min(exponential, options.maxDelay);
1438
- const spread = capped * options.jitter * (random() * 2 - 1);
1439
- return Math.max(0, Math.round(capped + spread));
1440
- }
1441
- /**
1442
- * Собирает планировщик повторов для транспорта.
1443
- *
1444
- * Поведение при `Retry-After`: пауза, названная сервером, соблюдается точно — она
1445
- * авторитетнее нашего расчёта. Но если сервер просит ждать дольше, чем `maxDelay`,
1446
- * повтор **не выполняется вовсе**: молча спать десять минут внутри вызова библиотека
1447
- * не должна, лучше отдать {@link ItdRateLimitError} и дать решить вызывающему коду.
1448
- *
1449
- * @param options настройки повторов после подстановки значений по умолчанию
1450
- * @param random источник случайности; подменяется в тестах ради предсказуемости
1451
- *
1452
- * @example
1453
- * ```ts
1454
- * const scheduler = createRetryScheduler(config.retry);
1455
- * const delay = scheduler(error, 1, 'GET'); // 500 мс ± 30%
1456
- * ```
1457
- */
1458
- function createRetryScheduler(options, random = Math.random) {
1459
- return (error, attempt, method, retryNetworkWrite = false) => {
1460
- if (attempt >= options.attempts) return void 0;
1461
- if (options.shouldRetry) return options.shouldRetry(error, attempt) ? backoffDelay(attempt, options, random) : void 0;
1462
- if (!isRetryable(error, method, options.retryWrites, retryNetworkWrite)) return void 0;
1463
- if (error instanceof require_storage.ItdApiError && error.retryAfter !== void 0) return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
1464
- return backoffDelay(attempt, options, random);
1465
- };
1466
- }
1467
- //#endregion
1468
- //#region src/core/middleware.ts
1469
- /** Ожидание повтора, которое уважает отмену запроса. */
1470
- function sleep(ms, signal) {
1471
- if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
1472
- if (signal.aborted) return Promise.reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1473
- return new Promise((resolve, reject) => {
1474
- const timer = setTimeout(() => {
1475
- signal.removeEventListener("abort", onAbort);
1476
- resolve();
1477
- }, ms);
1478
- const onAbort = () => {
1479
- clearTimeout(timer);
1480
- reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1481
- };
1482
- signal.addEventListener("abort", onAbort, { once: true });
1483
- });
1484
- }
1485
- /**
1486
- * Слой очереди: ограничение конкурентности и частоты.
1487
- *
1488
- * `skipQueue` пропускает запрос мимо очереди — так поступают служебные запросы, которые
1489
- * порождены изнутри другого запроса и не могут ждать освободившегося слота.
1490
- *
1491
- * Очередь выбирается по запросу: у каждого сервиса платформы свой хост и свой лимит.
1492
- */
1493
- function createQueueMiddleware(schedule) {
1494
- return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
1495
- }
1496
- /**
1497
- * Слой плагинов.
1498
- *
1499
- * Стоит снаружи повторов и внутри очереди: плагин должен увидеть запрос и ответ по одному
1500
- * разу, независимо от числа попыток, — иначе, например, текст поста зашифруется дважды.
1501
- */
1502
- function createPluginsMiddleware(plugins) {
1503
- return (request, next) => plugins.run(request, next);
1504
- }
1505
- /**
1506
- * Слой сервисов.
1507
- *
1508
- * Запросу с полем `service` подставляет хост сервиса, его заголовки и `skipAuth`, если
1509
- * сервис объявлен публичным. Заданный у запроса `baseUrl` не трогает.
1510
- *
1511
- * Стоит снаружи повторов и авторизации, чтобы выставленный здесь `skipAuth` был ей виден.
1512
- */
1513
- function createServicesMiddleware(registry) {
1514
- return async (request, next) => {
1515
- const service = request.service === void 0 ? void 0 : registry.require(request.service);
1516
- let prepared = request;
1517
- if (request.baseUrl !== void 0) {
1518
- const baseUrl = normalizeBaseUrl(request.baseUrl);
1519
- if (baseUrl !== request.baseUrl) prepared = {
1520
- ...prepared,
1521
- baseUrl
1522
- };
1523
- if (!(service?.baseUrl === baseUrl ? service.auth !== false : registry.isPrimarySite(baseUrl)) && prepared.skipAuth === void 0) prepared = {
1524
- ...prepared,
1525
- skipAuth: true
1526
- };
1527
- }
1528
- if (!service) return next(prepared);
1529
- if (prepared.baseUrl === void 0) prepared = {
1530
- ...prepared,
1531
- baseUrl: service.baseUrl
1532
- };
1533
- if (service.headers) prepared = withLayerHeaders(prepared, service.headers);
1534
- if (service.auth === false && prepared.skipAuth === void 0) prepared = {
1535
- ...prepared,
1536
- skipAuth: true
1537
- };
1538
- return next(prepared);
1539
- };
1540
- }
1541
- async function applyAuth(request, deps) {
1542
- if (request.skipAuth) return request;
1543
- const headers = await deps.getAuthHeaders();
1544
- return Object.keys(headers).length > 0 ? withLayerHeaders(request, headers) : request;
1545
- }
1546
- /**
1547
- * Слой авторизации.
1548
- *
1549
- * Подставляет заголовок `Authorization` и обрабатывает `401`: обновляет токен и повторяет
1550
- * запрос ровно один раз. Стоит внутри повторов, поэтому обычным попыткам он не виден —
1551
- * они уже работают со свежим токеном.
1552
- */
1553
- function createAuthMiddleware(deps) {
1554
- return async (request, next) => {
1555
- const authorized = await applyAuth(request, deps);
1556
- try {
1557
- return await next(authorized);
1558
- } catch (error) {
1559
- if (request.skipAuthRefresh || !deps.autoRefresh || !require_storage.isItdApiError(error) || error.status !== 401) throw error;
1560
- if (!await deps.onUnauthorized()) throw error;
1561
- return next(await applyAuth({
1562
- ...request,
1563
- skipAuthRefresh: true
1564
- }, deps));
1565
- }
1566
- };
1567
- }
1568
- /**
1569
- * Выбирает планировщик отката для конкретного запроса.
1570
- *
1571
- * `retry` у запроса переопределяет глобальную настройку: `false` выключает повторы,
1572
- * объект задаёт свои. Обработка `429` от этого не зависит — она общая.
1573
- */
1574
- function resolveBackoff(retry, global) {
1575
- if (retry === void 0) return global;
1576
- if (retry === false) return void 0;
1577
- const resolved = resolveRetry(retry);
1578
- return resolved ? createRetryScheduler(resolved) : void 0;
1579
- }
1580
- /**
1581
- * Слой повторов.
1582
- *
1583
- * Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
1584
- * всей очереди; сетевые сбои и `5xx` — экспоненциальным откатом. Настройка `retry`
1585
- * у отдельного запроса имеет приоритет над глобальной.
1586
- */
1587
- function createRetryMiddleware(deps) {
1588
- const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
1589
- const nextDelay = (error, attempt, request, method, backoff) => {
1590
- if (require_storage.isItdRateLimitError(error)) {
1591
- const wait = error.retryAfter ?? deps.rateLimitDelays[attempt - 1];
1592
- if (wait === void 0) return void 0;
1593
- deps.pauseQueue?.(wait, request);
1594
- deps.logger?.debug(`лимит частоты, попытка ${attempt + 1} через ${wait} мс`);
1595
- return wait;
1596
- }
1597
- return backoff?.(error, attempt, method, request.retryNetworkWrite ?? false);
1598
- };
1599
- return async (request, next) => {
1600
- const method = request.method.toUpperCase();
1601
- const backoff = resolveBackoff(request.retry, globalScheduler);
1602
- for (let attempt = 1;; attempt++) try {
1603
- return await next({
1604
- ...request,
1605
- attempt
1606
- });
1607
- } catch (error) {
1608
- const delay = nextDelay(error, attempt, request, method, backoff);
1609
- if (delay === void 0) throw error;
1610
- await dispatchRequestHook(deps.hooks, "onRetry", {
1611
- method,
1612
- path: request.path,
1613
- url: deps.buildUrl(request),
1614
- headers: new Headers({
1615
- ...request.layerHeaders,
1616
- ...request.headers
1617
- }),
1618
- attempt,
1619
- error,
1620
- delay
1621
- }, request);
1622
- deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${attempt + 1} через ${delay} мс`);
1623
- await sleep(delay, request.signal);
1624
- }
1625
- };
1626
- }
1627
- //#endregion
1628
1650
  //#region src/core/rate-limit.ts
1629
1651
  /** Ошибка отмены запроса, который ещё не дошёл до транспорта. */
1630
1652
  function queueAbortError() {
@@ -1649,10 +1671,12 @@ var RequestQueue = class {
1649
1671
  #active = 0;
1650
1672
  /** Момент, раньше которого следующий запрос стартовать не должен. */
1651
1673
  #nextSlot = 0;
1652
- #timer;
1653
- constructor(options) {
1674
+ #clock;
1675
+ #cancelTimer;
1676
+ constructor(options, clock = systemClock) {
1654
1677
  this.#concurrency = options.concurrency;
1655
1678
  this.#minGap = options.rps ? 1e3 / options.rps : 0;
1679
+ this.#clock = clock;
1656
1680
  }
1657
1681
  /** Сколько задач выполняется прямо сейчас. */
1658
1682
  get active() {
@@ -1704,9 +1728,9 @@ var RequestQueue = class {
1704
1728
  * ошибкой `ItdAbortError`. Уже выполняющиеся задачи доводятся до конца.
1705
1729
  */
1706
1730
  stop() {
1707
- if (this.#timer !== void 0) {
1708
- clearTimeout(this.#timer);
1709
- this.#timer = void 0;
1731
+ if (this.#cancelTimer) {
1732
+ this.#cancelTimer();
1733
+ this.#cancelTimer = void 0;
1710
1734
  }
1711
1735
  this.#nextSlot = 0;
1712
1736
  const pending = this.#waiting.splice(0, this.#waiting.length);
@@ -1720,23 +1744,23 @@ var RequestQueue = class {
1720
1744
  */
1721
1745
  pause(ms) {
1722
1746
  if (ms <= 0) return;
1723
- this.#nextSlot = Math.max(this.#nextSlot, Date.now() + ms);
1747
+ this.#nextSlot = Math.max(this.#nextSlot, this.#clock.now() + ms);
1724
1748
  }
1725
1749
  /** Запускает столько ожидающих задач, сколько позволяют ограничения. */
1726
1750
  #drain() {
1727
1751
  if (this.#waiting.length === 0) {
1728
- if (this.#timer !== void 0) {
1729
- clearTimeout(this.#timer);
1730
- this.#timer = void 0;
1752
+ if (this.#cancelTimer) {
1753
+ this.#cancelTimer();
1754
+ this.#cancelTimer = void 0;
1731
1755
  }
1732
1756
  return;
1733
1757
  }
1734
1758
  if (this.#active >= this.#concurrency) return;
1735
- if (this.#timer !== void 0) return;
1736
- const now = Date.now();
1759
+ if (this.#cancelTimer) return;
1760
+ const now = this.#clock.now();
1737
1761
  if (this.#nextSlot > now) {
1738
- this.#timer = setTimeout(() => {
1739
- this.#timer = void 0;
1762
+ this.#cancelTimer = this.#clock.schedule(() => {
1763
+ this.#cancelTimer = void 0;
1740
1764
  this.#drain();
1741
1765
  }, this.#nextSlot - now);
1742
1766
  return;
@@ -1755,19 +1779,21 @@ var RequestQueue = class {
1755
1779
  */
1756
1780
  var RequestQueuePool = class {
1757
1781
  #options;
1782
+ #clock;
1758
1783
  #main;
1759
1784
  /** Очереди сервисов заводятся при первом запросе — обычно не нужна ни одна. */
1760
1785
  #byService = /* @__PURE__ */ new Map();
1761
- constructor(options) {
1786
+ constructor(options, clock = systemClock) {
1762
1787
  this.#options = options;
1763
- this.#main = new RequestQueue(options);
1788
+ this.#clock = clock;
1789
+ this.#main = new RequestQueue(options, clock);
1764
1790
  }
1765
1791
  /** Очередь хоста. */
1766
1792
  for(service) {
1767
1793
  if (service === void 0) return this.#main;
1768
1794
  let queue = this.#byService.get(service);
1769
1795
  if (!queue) {
1770
- queue = new RequestQueue(this.#options);
1796
+ queue = new RequestQueue(this.#options, this.#clock);
1771
1797
  this.#byService.set(service, queue);
1772
1798
  }
1773
1799
  return queue;
@@ -2215,7 +2241,7 @@ function createApiError(context) {
2215
2241
  path: context.path,
2216
2242
  raw: safeRawBody(context.body),
2217
2243
  response: context.response,
2218
- retryAfter: parseRetryAfter(context.headers?.get("retry-after"))
2244
+ retryAfter: parseRetryAfter(context.headers?.get("retry-after"), context.now)
2219
2245
  };
2220
2246
  if (parsed.code === "PHONE_VERIFICATION_REQUIRED") return new require_storage.ItdPhoneVerificationError({
2221
2247
  ...init,
@@ -2291,13 +2317,13 @@ function abortable(promise, signal) {
2291
2317
  * Реализовано вручную, а не через `AbortSignal.any`: последний появился только в Node 20,
2292
2318
  * а библиотека поддерживает Node 18.
2293
2319
  */
2294
- function createAbortBundle(userSignal, timeout) {
2320
+ function createAbortBundle(userSignal, timeout, clock) {
2295
2321
  const controller = new AbortController();
2296
2322
  let timedOut = false;
2297
2323
  const onUserAbort = () => controller.abort(userSignal?.reason);
2298
2324
  if (userSignal) if (userSignal.aborted) controller.abort(userSignal.reason);
2299
2325
  else userSignal.addEventListener("abort", onUserAbort, { once: true });
2300
- const timer = timeout > 0 ? setTimeout(() => {
2326
+ const cancelTimer = timeout > 0 ? clock.schedule(() => {
2301
2327
  timedOut = true;
2302
2328
  controller.abort();
2303
2329
  }, timeout) : void 0;
@@ -2305,7 +2331,7 @@ function createAbortBundle(userSignal, timeout) {
2305
2331
  signal: controller.signal,
2306
2332
  timedOut: () => timedOut,
2307
2333
  cleanup: () => {
2308
- if (timer !== void 0) clearTimeout(timer);
2334
+ cancelTimer?.();
2309
2335
  userSignal?.removeEventListener("abort", onUserAbort);
2310
2336
  }
2311
2337
  };
@@ -2342,8 +2368,8 @@ var Transport = class {
2342
2368
  const headers = await this.#buildHeaders(request, url);
2343
2369
  const attempt = request.attempt ?? 1;
2344
2370
  const timeout = request.timeout ?? this.#config.timeout;
2345
- const abort = createAbortBundle(request.signal, timeout);
2346
- const startedAt = Date.now();
2371
+ const abort = createAbortBundle(request.signal, timeout, this.#config.clock);
2372
+ const startedAt = this.#config.clock.now();
2347
2373
  let cleanupBody;
2348
2374
  try {
2349
2375
  let body;
@@ -2362,7 +2388,7 @@ var Transport = class {
2362
2388
  };
2363
2389
  await dispatchRequestHook(this.#config.hooks, "onError", {
2364
2390
  ...context,
2365
- duration: Date.now() - startedAt,
2391
+ duration: this.#config.clock.now() - startedAt,
2366
2392
  error: failure
2367
2393
  }, request);
2368
2394
  throw failure;
@@ -2391,7 +2417,7 @@ var Transport = class {
2391
2417
  if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) init.duplex = "half";
2392
2418
  response = await this.#config.fetch(url, init);
2393
2419
  } catch (error) {
2394
- const duration = Date.now() - startedAt;
2420
+ const duration = this.#config.clock.now() - startedAt;
2395
2421
  const failure = this.#toTransportError(error, abort, request, method, timeout);
2396
2422
  await dispatchRequestHook(this.#config.hooks, "onError", {
2397
2423
  ...context,
@@ -2409,16 +2435,17 @@ var Transport = class {
2409
2435
  if (response.ok) await dispatchRequestHook(this.#config.hooks, "onResponse", {
2410
2436
  ...context,
2411
2437
  status: response.status,
2412
- duration: Date.now() - startedAt,
2438
+ duration: this.#config.clock.now() - startedAt,
2413
2439
  response
2414
2440
  }, request);
2415
2441
  const payload = await this.#readBodyOrFail(response, context, request, method, abort, timeout);
2416
- const duration = Date.now() - startedAt;
2442
+ const duration = this.#config.clock.now() - startedAt;
2417
2443
  if (!response.ok) {
2418
2444
  const error = createApiError({
2419
2445
  method,
2420
2446
  path: request.path,
2421
2447
  status: response.status,
2448
+ now: this.#config.clock.now(),
2422
2449
  statusText: response.statusText,
2423
2450
  headers: response.headers,
2424
2451
  response,
@@ -2487,7 +2514,7 @@ var Transport = class {
2487
2514
  }
2488
2515
  /** Читает тело и преобразует ошибку чтения в транспортную ошибку библиотеки. */
2489
2516
  async #readBodyOrFail(response, context, request, method, abort, timeout) {
2490
- const startedAt = Date.now();
2517
+ const startedAt = this.#config.clock.now();
2491
2518
  try {
2492
2519
  return await abortable(readBody(response), abort.signal);
2493
2520
  } catch (error) {
@@ -2495,7 +2522,7 @@ var Transport = class {
2495
2522
  const failure = this.#toTransportError(error, abort, request, method, timeout);
2496
2523
  await dispatchRequestHook(this.#config.hooks, "onError", {
2497
2524
  ...context,
2498
- duration: Date.now() - startedAt,
2525
+ duration: this.#config.clock.now() - startedAt,
2499
2526
  error: failure
2500
2527
  }, request);
2501
2528
  this.#config.logger?.warn(`× ${method} ${request.path}: не удалось прочитать тело ответа — ${failure.message}`);
@@ -2816,144 +2843,167 @@ const ItdErrorCode = Object.freeze({
2816
2843
  WRITE_ACCESS_RESTRICTED: "WRITE_ACCESS_RESTRICTED"
2817
2844
  });
2818
2845
  //#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
2846
+ //#region src/realtime/middleware.ts
2847
+ const REALTIME_MIDDLEWARE_SNAPSHOT = Symbol("itd-api.realtime.middlewareSnapshot");
2848
+ /** @internal */
2849
+ function captureRealtimeMiddleware(middleware) {
2850
+ const capture = middleware[REALTIME_MIDDLEWARE_SNAPSHOT];
2851
+ if (!capture) return middleware;
2852
+ const snapshot = capture();
2853
+ if (typeof snapshot !== "function") throw new TypeError("Снимок промежуточного обработчика должен быть функцией");
2854
+ return snapshot;
2855
+ }
2856
+ /** @internal */
2857
+ function withRealtimeMiddlewareSnapshot(middleware, capture) {
2858
+ Object.defineProperty(middleware, REALTIME_MIDDLEWARE_SNAPSHOT, { value: capture });
2859
+ return middleware;
2860
+ }
2861
+ /** Выполняет промежуточные обработчики по порядку и запрещает повторный вызов `next()`. */
2862
+ async function runRealtimeMiddleware(middleware, context, terminal) {
2863
+ let lastIndex = -1;
2864
+ const dispatch = async (index) => {
2865
+ if (index <= lastIndex) throw new Error("next() в промежуточном обработчике вызван повторно");
2866
+ lastIndex = index;
2867
+ const current = middleware[index];
2868
+ if (!current) {
2869
+ await terminal();
2870
+ return;
2871
+ }
2872
+ let downstream;
2873
+ let duplicateCalls;
2874
+ let failure;
2875
+ const next = () => {
2876
+ if (!downstream) {
2877
+ downstream = dispatch(index + 1);
2878
+ return downstream;
2879
+ }
2880
+ const duplicate = Promise.reject(/* @__PURE__ */ new Error("next() в одном промежуточном обработчике вызван повторно"));
2881
+ duplicateCalls = Promise.all(duplicateCalls ? [duplicateCalls, duplicate] : [duplicate]).then(() => void 0);
2882
+ return duplicate;
2883
+ };
2884
+ try {
2885
+ await current(context, next);
2886
+ } catch (error) {
2887
+ failure = { error };
2888
+ }
2889
+ try {
2890
+ await downstream;
2891
+ await duplicateCalls;
2892
+ } catch (error) {
2893
+ failure ??= { error };
2894
+ }
2895
+ if (failure) throw failure.error;
2944
2896
  };
2897
+ await dispatch(0);
2945
2898
  }
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
- }
2899
+ /** Планирует нормализованные обновления и отслеживает незавершённые обработчики. */
2900
+ var RealtimeDispatcher = class {
2901
+ #options;
2902
+ #hooks;
2903
+ #middleware = [];
2904
+ #handlers = [];
2905
+ #queue = [];
2906
+ #activeKeys = /* @__PURE__ */ new Set();
2907
+ #drainWaiters = /* @__PURE__ */ new Set();
2908
+ #active = 0;
2909
+ constructor(options, hooks) {
2910
+ this.#options = options;
2911
+ this.#hooks = hooks;
2912
+ }
2913
+ use(middleware) {
2914
+ this.#middleware.push(middleware);
2915
+ return () => {
2916
+ const index = this.#middleware.indexOf(middleware);
2917
+ if (index >= 0) this.#middleware.splice(index, 1);
2918
+ };
2919
+ }
2920
+ on(predicate, handler) {
2921
+ const registration = {
2922
+ predicate,
2923
+ handler
2924
+ };
2925
+ this.#handlers.push(registration);
2926
+ return () => {
2927
+ const index = this.#handlers.indexOf(registration);
2928
+ if (index >= 0) this.#handlers.splice(index, 1);
2929
+ };
2930
+ }
2931
+ dispatch(context) {
2932
+ let keys;
2933
+ let middleware;
2934
+ try {
2935
+ keys = this.#keysFor(context);
2936
+ middleware = this.#middleware.map(captureRealtimeMiddleware);
2937
+ } catch (error) {
2938
+ this.#hooks.middlewareError(error, context);
2939
+ return;
2940
+ }
2941
+ this.#queue.push({
2942
+ context,
2943
+ middleware,
2944
+ handlers: [...this.#handlers],
2945
+ keys
2946
+ });
2947
+ this.#pump();
2948
+ }
2949
+ /** Отбрасывает обновления, обработка которых ещё не началась. */
2950
+ clearPending() {
2951
+ this.#queue.length = 0;
2952
+ this.#resolveDrain();
2953
+ }
2954
+ /** Ждёт завершения активных и поставленных в очередь обновлений. */
2955
+ drain() {
2956
+ if (this.#active === 0 && this.#queue.length === 0) return Promise.resolve();
2957
+ return new Promise((resolve) => this.#drainWaiters.add(resolve));
2958
+ }
2959
+ #keysFor(context) {
2960
+ const value = this.#options.sequentialize?.(context);
2961
+ if (value === void 0) return [];
2962
+ const values = Array.isArray(value) ? value : [value];
2963
+ for (const key of values) if (typeof key !== "string" && typeof key !== "number" && typeof key !== "symbol") throw new TypeError("sequentialize() должен возвращать PropertyKey или их список");
2964
+ return [...new Set(values)];
2965
+ }
2966
+ #pump() {
2967
+ while (this.#active < this.#options.concurrency) {
2968
+ const blockedKeys = new Set(this.#activeKeys);
2969
+ const index = this.#queue.findIndex(({ keys }) => {
2970
+ const runnable = keys.every((key) => !blockedKeys.has(key));
2971
+ if (!runnable) for (const key of keys) blockedKeys.add(key);
2972
+ return runnable;
2973
+ });
2974
+ if (index < 0) break;
2975
+ const [work] = this.#queue.splice(index, 1);
2976
+ if (!work) break;
2977
+ this.#active += 1;
2978
+ for (const key of work.keys) this.#activeKeys.add(key);
2979
+ this.#run(work);
2980
+ }
2981
+ this.#resolveDrain();
2982
+ }
2983
+ async #run(work) {
2984
+ try {
2985
+ await runRealtimeMiddleware(work.middleware, work.context, async () => {
2986
+ for (const { predicate, handler } of work.handlers) try {
2987
+ if (predicate(work.context)) await handler(work.context);
2988
+ } catch (error) {
2989
+ this.#hooks.handlerError(error, work.context);
2990
+ }
2991
+ this.#hooks.deliver(work.context);
2992
+ });
2993
+ } catch (error) {
2994
+ this.#hooks.middlewareError(error, work.context);
2995
+ } finally {
2996
+ this.#active -= 1;
2997
+ for (const key of work.keys) this.#activeKeys.delete(key);
2998
+ this.#pump();
2999
+ }
3000
+ }
3001
+ #resolveDrain() {
3002
+ if (this.#active !== 0 || this.#queue.length !== 0) return;
3003
+ for (const resolve of this.#drainWaiters) resolve();
3004
+ this.#drainWaiters.clear();
3005
+ }
3006
+ };
2957
3007
  //#endregion
2958
3008
  //#region src/realtime/transport.ts
2959
3009
  /** Ошибка, по которой видно, что сервер отверг авторизацию потока. */
@@ -2979,7 +3029,9 @@ var PollTransport = class {
2979
3029
  name = "poll";
2980
3030
  #interval;
2981
3031
  #limit;
3032
+ #clock;
2982
3033
  constructor(options = {}) {
3034
+ this.#clock = options.clock ?? systemClock;
2983
3035
  this.#interval = options.interval ?? 15e3;
2984
3036
  this.#limit = options.limit ?? 20;
2985
3037
  }
@@ -3047,9 +3099,9 @@ var PollTransport = class {
3047
3099
  #wait(signal) {
3048
3100
  if (signal.aborted) return Promise.resolve();
3049
3101
  return new Promise((resolve) => {
3050
- const timer = setTimeout(finish, this.#interval);
3102
+ const cancel = this.#clock.schedule(finish, this.#interval);
3051
3103
  function finish() {
3052
- clearTimeout(timer);
3104
+ cancel();
3053
3105
  signal.removeEventListener("abort", finish);
3054
3106
  resolve();
3055
3107
  }
@@ -3286,9 +3338,11 @@ var SseTransport = class {
3286
3338
  name = "sse";
3287
3339
  #idleTimeout;
3288
3340
  #handshakeTimeout;
3341
+ #clock;
3289
3342
  /** Идентификатор последнего события — отправляется при переподключении. */
3290
3343
  #lastEventId;
3291
3344
  constructor(options = {}) {
3345
+ this.#clock = options.clock ?? systemClock;
3292
3346
  this.#idleTimeout = options.idleTimeout ?? 9e4;
3293
3347
  this.#handshakeTimeout = options.handshakeTimeout ?? 2e4;
3294
3348
  }
@@ -3316,66 +3370,274 @@ var SseTransport = class {
3316
3370
  context.signal.removeEventListener("abort", relayAbort);
3317
3371
  }
3318
3372
  }
3319
- /** Выполняет запрос потока, обрывая его, если ответ не пришёл за отведённое время. */
3320
- async #handshake(url, headers, context, controller) {
3321
- let expired = false;
3322
- const timer = this.#handshakeTimeout > 0 ? setTimeout(() => {
3323
- expired = true;
3324
- controller.abort(/* @__PURE__ */ new Error("Истёк таймаут рукопожатия SSE"));
3325
- }, this.#handshakeTimeout) : void 0;
3326
- try {
3327
- return await context.fetch(url, {
3328
- method: "GET",
3329
- headers,
3330
- signal: controller.signal
3331
- });
3332
- } catch (error) {
3333
- if (expired) throw new Error("Поток уведомлений не ответил: истёк таймаут рукопожатия");
3334
- throw error;
3335
- } finally {
3336
- if (timer !== void 0) clearTimeout(timer);
3337
- }
3373
+ /** Выполняет запрос потока, обрывая его, если ответ не пришёл за отведённое время. */
3374
+ async #handshake(url, headers, context, controller) {
3375
+ let expired = false;
3376
+ const cancelTimer = this.#handshakeTimeout > 0 ? this.#clock.schedule(() => {
3377
+ expired = true;
3378
+ controller.abort(/* @__PURE__ */ new Error("Истёк таймаут рукопожатия SSE"));
3379
+ }, this.#handshakeTimeout) : void 0;
3380
+ try {
3381
+ return await context.fetch(url, {
3382
+ method: "GET",
3383
+ headers,
3384
+ signal: controller.signal
3385
+ });
3386
+ } catch (error) {
3387
+ if (expired) throw new Error("Поток уведомлений не ответил: истёк таймаут рукопожатия");
3388
+ throw error;
3389
+ } finally {
3390
+ cancelTimer?.();
3391
+ }
3392
+ }
3393
+ async #read(body, context) {
3394
+ const reader = body.getReader();
3395
+ const decoder = new TextDecoder();
3396
+ const parser = createParser({ onEvent: (message) => {
3397
+ if (message.id) this.#lastEventId = message.id;
3398
+ let data;
3399
+ try {
3400
+ data = JSON.parse(message.data);
3401
+ } catch (error) {
3402
+ context.onParseError(error, message.data);
3403
+ return;
3404
+ }
3405
+ const name = message.event ?? (typeof data === "object" && data !== null && "type" in data ? String(data.type) : "message");
3406
+ context.onEvent({
3407
+ name,
3408
+ data
3409
+ });
3410
+ } });
3411
+ let cancelIdleTimer;
3412
+ const armIdleTimer = () => {
3413
+ if (this.#idleTimeout <= 0) return;
3414
+ cancelIdleTimer?.();
3415
+ cancelIdleTimer = this.#clock.schedule(() => {
3416
+ reader.cancel(/* @__PURE__ */ new Error("Поток молчит дольше допустимого")).catch(() => {});
3417
+ }, this.#idleTimeout);
3418
+ };
3419
+ armIdleTimer();
3420
+ try {
3421
+ for (;;) {
3422
+ const { done, value } = await reader.read();
3423
+ if (done) break;
3424
+ armIdleTimer();
3425
+ parser.feed(decoder.decode(value, { stream: true }));
3426
+ }
3427
+ } finally {
3428
+ cancelIdleTimer?.();
3429
+ reader.releaseLock?.();
3430
+ }
3431
+ }
3432
+ };
3433
+ //#endregion
3434
+ //#region src/notifications/type-map.ts
3435
+ /**
3436
+ * Соответствие коротких имён типов уведомлений развёрнутым.
3437
+ *
3438
+ * Сервер — и в списке, и в потоке событий — присылает короткие имена: `like`, `comment`,
3439
+ * `reply`, `repost`, `comment_like`. Развёрнутые (`post_reaction`, `post_comment`)
3440
+ * встречаются в оформлении интерфейса, поэтому библиотека приводит типы к ним:
3441
+ * они однозначно называют и объект, и действие.
3442
+ *
3443
+ * Пришедшее значение всегда остаётся в поле `rawType`.
3444
+ */
3445
+ const NOTIFICATION_TYPE_ALIASES = Object.freeze({
3446
+ like: NotificationType.PostReaction,
3447
+ comment: NotificationType.PostComment,
3448
+ comment_like: NotificationType.CommentReaction,
3449
+ reply: NotificationType.CommentReply,
3450
+ repost: NotificationType.PostRepost,
3451
+ mention: NotificationType.PostMention
3452
+ });
3453
+ const KNOWN_TYPES = new Set(Object.values(NotificationType));
3454
+ /**
3455
+ * Приводит имя типа к каноническому.
3456
+ *
3457
+ * Неизвестное значение возвращается без изменений, чтобы не менять смысл нового типа
3458
+ * уведомления на другой.
3459
+ *
3460
+ * @example
3461
+ * ```ts
3462
+ * canonicalNotificationType('like'); // 'post_reaction'
3463
+ * canonicalNotificationType('post_reaction'); // 'post_reaction'
3464
+ * canonicalNotificationType('новое_событие'); // 'новое_событие'
3465
+ * ```
3466
+ */
3467
+ function canonicalNotificationType(rawType) {
3468
+ return NOTIFICATION_TYPE_ALIASES[rawType] ?? rawType;
3469
+ }
3470
+ /**
3471
+ * Известен ли библиотеке этот тип уведомления.
3472
+ *
3473
+ * Полезно, чтобы решить, показывать ли уведомление, для которого нет своего оформления.
3474
+ */
3475
+ function isKnownNotificationType(type) {
3476
+ return KNOWN_TYPES.has(canonicalNotificationType(type));
3477
+ }
3478
+ //#endregion
3479
+ //#region src/notifications/normalize.ts
3480
+ function asActor(value) {
3481
+ if (!isRecord(value)) return void 0;
3482
+ const id = asString(value.id);
3483
+ if (!id) return void 0;
3484
+ return {
3485
+ id,
3486
+ username: asString(value.username) ?? "",
3487
+ displayName: asString(value.displayName) ?? "",
3488
+ avatar: asString(value.avatar) ?? "",
3489
+ ...typeof value.isFollowing === "boolean" ? { isFollowing: value.isFollowing } : {},
3490
+ ...typeof value.isFollowedBy === "boolean" ? { isFollowedBy: value.isFollowedBy } : {}
3491
+ };
3492
+ }
3493
+ /** Собирает участников: сервер присылает либо одного `actor`, либо массив `actors`. */
3494
+ function readActors(source) {
3495
+ if (Array.isArray(source.actors)) return source.actors.map(asActor).filter((actor) => actor !== void 0);
3496
+ const single = asActor(source.actor);
3497
+ return single ? [single] : [];
3498
+ }
3499
+ /**
3500
+ * Приводит уведомление к единой форме.
3501
+ *
3502
+ * Нужна потому, что REST-список и поток событий описывают одно и то же событие по-разному:
3503
+ * различаются имена типов (`like` против `post_reaction`), имена полей
3504
+ * (`targetId`/`entityId`, `read`/`isRead`, `preview`/`entityPreview`) и число участников
3505
+ * (`actor` против массива `actors`). После приведения объекты из обоих источников
3506
+ * можно складывать в один список.
3507
+ *
3508
+ * Исходные данные не теряются: имя типа с сервера остаётся в `rawType`,
3509
+ * весь объект целиком — в `raw`.
3510
+ *
3511
+ * @param input уведомление из REST-ответа либо полезная нагрузка события потока
3512
+ *
3513
+ * @example
3514
+ * ```ts
3515
+ * const fromRest = normalizeNotification(restItem);
3516
+ * const fromStream = normalizeNotification(event.payload);
3517
+ * // одинаковая форма — можно объединять
3518
+ * ```
3519
+ */
3520
+ function normalizeNotification(input) {
3521
+ const source = isRecord(input) ? input : {};
3522
+ const payload = isRecord(source.payload) ? source.payload : source;
3523
+ const rawType = asString(payload.type) ?? asString(source.type) ?? "";
3524
+ const createdAt = asString(payload.createdAt) ?? asString(source.createdAt) ?? "";
3525
+ const readAt = asString(payload.readAt) ?? asString(source.readAt);
3526
+ const isRead = typeof payload.isRead === "boolean" ? payload.isRead : typeof payload.read === "boolean" ? payload.read : Boolean(readAt);
3527
+ const subjectId = asString(payload.subjectId);
3528
+ const targetId = asString(payload.targetId);
3529
+ const subjectIsComment = payload.subjectType === "comment";
3530
+ const clickUrl = asString(payload.clickUrl);
3531
+ return {
3532
+ id: asString(payload.id) ?? asString(source.id) ?? "",
3533
+ type: canonicalNotificationType(rawType),
3534
+ rawType,
3535
+ entityId: asString(payload.entityId) ?? (subjectIsComment ? subjectId ?? targetId : targetId) ?? null,
3536
+ parentEntityId: asString(payload.parentEntityId) ?? (subjectIsComment ? targetId ?? null : null),
3537
+ isRead,
3538
+ actors: readActors(payload),
3539
+ count: typeof payload.count === "number" && payload.count > 0 ? payload.count : 1,
3540
+ preview: asString(payload.entityPreview) ?? asString(payload.preview) ?? null,
3541
+ ...clickUrl ? { clickUrl } : {},
3542
+ createdAt,
3543
+ updatedAt: asString(payload.updatedAt) ?? readAt ?? createdAt,
3544
+ raw: input
3545
+ };
3546
+ }
3547
+ /**
3548
+ * Разбирает событие `notification` из потока.
3549
+ *
3550
+ * Кроме самого уведомления событие несёт служебные поля уровня конверта: актуальный
3551
+ * счётчик непрочитанных и признак звука.
3552
+ */
3553
+ function readNotificationEvent(data) {
3554
+ const source = isRecord(data) ? data : {};
3555
+ return {
3556
+ notification: normalizeNotification(data),
3557
+ unreadCount: typeof source.unreadCount === "number" ? source.unreadCount : void 0,
3558
+ sound: source.sound === true
3559
+ };
3560
+ }
3561
+ /**
3562
+ * Разбирает событие `unread_count` из потока.
3563
+ *
3564
+ * Возвращает `undefined`, если сервер прислал событие без вложенного `payload`.
3565
+ */
3566
+ function readUnreadCountEvent(data) {
3567
+ if (!isRecord(data)) return void 0;
3568
+ const payload = isRecord(data.payload) ? data.payload : void 0;
3569
+ if (!payload) return void 0;
3570
+ return typeof payload.count === "number" ? payload.count : void 0;
3571
+ }
3572
+ //#endregion
3573
+ //#region src/realtime/updates.ts
3574
+ /** Типы нормализованных обновлений потока. */
3575
+ const RealtimeUpdateType = Object.freeze({
3576
+ Notification: "notification",
3577
+ UnreadCount: "unreadCount",
3578
+ Unknown: "unknown"
3579
+ });
3580
+ /** Источники нормализованных обновлений потока. */
3581
+ const RealtimeUpdateOrigin = Object.freeze({
3582
+ Stream: "stream",
3583
+ Sync: "sync"
3584
+ });
3585
+ /** Проверяет форму фильтра уведомлений. */
3586
+ function validateNotificationSelector(selector) {
3587
+ if (typeof selector === "string") {
3588
+ if (selector.length === 0) throw new require_storage.ItdConfigError("Тип уведомления не должен быть пустым");
3589
+ return;
3590
+ }
3591
+ if (Array.isArray(selector)) {
3592
+ if (selector.length === 0 || selector.some((type) => typeof type !== "string" || !type)) throw new require_storage.ItdConfigError("Список типов уведомлений должен содержать непустые строки");
3593
+ return;
3338
3594
  }
3339
- async #read(body, context) {
3340
- const reader = body.getReader();
3341
- const decoder = new TextDecoder();
3342
- const parser = createParser({ onEvent: (message) => {
3343
- if (message.id) this.#lastEventId = message.id;
3344
- let data;
3345
- try {
3346
- data = JSON.parse(message.data);
3347
- } catch (error) {
3348
- context.onParseError(error, message.data);
3349
- return;
3350
- }
3351
- const name = message.event ?? (typeof data === "object" && data !== null && "type" in data ? String(data.type) : "message");
3352
- context.onEvent({
3353
- name,
3354
- data
3355
- });
3356
- } });
3357
- let idleTimer;
3358
- const armIdleTimer = () => {
3359
- if (this.#idleTimeout <= 0) return;
3360
- if (idleTimer !== void 0) clearTimeout(idleTimer);
3361
- idleTimer = setTimeout(() => {
3362
- reader.cancel(/* @__PURE__ */ new Error("Поток молчит дольше допустимого")).catch(() => {});
3363
- }, this.#idleTimeout);
3595
+ if (typeof selector !== "object" || selector === null) throw new require_storage.ItdConfigError("Фильтр уведомлений должен быть строкой, списком или объектом");
3596
+ const filter = selector;
3597
+ if (filter.type !== void 0) validateNotificationSelector(filter.type);
3598
+ if (filter.actorId !== void 0 && typeof filter.actorId !== "string") throw new require_storage.ItdConfigError("Фильтр уведомлений: actorId должен быть строкой");
3599
+ for (const field of ["entityId", "parentEntityId"]) {
3600
+ const value = filter[field];
3601
+ if (value !== void 0 && value !== null && typeof value !== "string") throw new require_storage.ItdConfigError(`Фильтр уведомлений: ${field} должен быть строкой или null`);
3602
+ }
3603
+ if (filter.predicate !== void 0 && typeof filter.predicate !== "function") throw new require_storage.ItdConfigError("Фильтр уведомлений: predicate должен быть функцией");
3604
+ }
3605
+ /** Преобразует транспортный кадр в одно логическое обновление. */
3606
+ function readRealtimeUpdate(event) {
3607
+ if (event.name === "notification") return {
3608
+ type: RealtimeUpdateType.Notification,
3609
+ data: readNotificationEvent(event.data)
3610
+ };
3611
+ if (event.name === "unread_count") {
3612
+ const count = readUnreadCountEvent(event.data);
3613
+ return count === void 0 ? void 0 : {
3614
+ type: RealtimeUpdateType.UnreadCount,
3615
+ data: count
3364
3616
  };
3365
- armIdleTimer();
3366
- try {
3367
- for (;;) {
3368
- const { done, value } = await reader.read();
3369
- if (done) break;
3370
- armIdleTimer();
3371
- parser.feed(decoder.decode(value, { stream: true }));
3372
- }
3373
- } finally {
3374
- if (idleTimer !== void 0) clearTimeout(idleTimer);
3375
- reader.releaseLock?.();
3376
- }
3377
3617
  }
3378
- };
3618
+ return {
3619
+ type: RealtimeUpdateType.Unknown,
3620
+ name: event.name,
3621
+ data: event.data
3622
+ };
3623
+ }
3624
+ /** Проверяет объектный или краткий фильтр уведомления. */
3625
+ function matchesNotification(context, selector) {
3626
+ const notification = context.update.data.notification;
3627
+ if (typeof selector === "string") return notification.type === selector;
3628
+ if (Array.isArray(selector)) return selector.includes(notification.type);
3629
+ const filter = selector;
3630
+ const types = filter.type === void 0 ? void 0 : [filter.type].flat();
3631
+ if (types && !types.includes(notification.type)) return false;
3632
+ if (filter.actorId !== void 0 && !notification.actors.some(({ id }) => id === filter.actorId)) return false;
3633
+ if (filter.entityId !== void 0 && notification.entityId !== filter.entityId) return false;
3634
+ if (filter.parentEntityId !== void 0 && notification.parentEntityId !== filter.parentEntityId) return false;
3635
+ return filter.predicate?.(context) ?? true;
3636
+ }
3637
+ /** Сужает произвольный контекст потока до контекста уведомления. */
3638
+ function isNotificationContext(context) {
3639
+ return context.update.type === RealtimeUpdateType.Notification;
3640
+ }
3379
3641
  //#endregion
3380
3642
  //#region src/realtime/stream.ts
3381
3643
  /** Способ получения событий. */
@@ -3398,6 +3660,8 @@ function validateRealtimeOptions(options) {
3398
3660
  if (!Number.isFinite(value) || value < min) throw new require_storage.ItdConfigError(`realtime.${name} должен быть числом не меньше ${min}, получено: ${value}`);
3399
3661
  };
3400
3662
  positiveInteger(options.maxAttempts, "maxAttempts");
3663
+ positiveInteger(options.concurrency, "concurrency");
3664
+ if (options.concurrency === 0) throw new require_storage.ItdConfigError("realtime.concurrency должен быть больше нуля");
3401
3665
  duration(options.pollInterval, "pollInterval", 1);
3402
3666
  duration(options.idleTimeout, "idleTimeout", 0);
3403
3667
  duration(options.handshakeTimeout, "handshakeTimeout", 0);
@@ -3406,6 +3670,7 @@ function validateRealtimeOptions(options) {
3406
3670
  if (!Array.isArray(options.backoff) || options.backoff.length === 0) throw new require_storage.ItdConfigError("realtime.backoff должен быть непустым списком пауз");
3407
3671
  for (const delay of options.backoff) duration(delay, "backoff", 0);
3408
3672
  }
3673
+ if (options.sequentialize !== void 0 && typeof options.sequentialize !== "function") throw new require_storage.ItdConfigError("realtime.sequentialize должен быть функцией");
3409
3674
  }
3410
3675
  /**
3411
3676
  * Поток уведомлений в реальном времени.
@@ -3415,22 +3680,26 @@ function validateRealtimeOptions(options) {
3415
3680
  *
3416
3681
  * @example
3417
3682
  * ```ts
3683
+ * import { NotificationType } from 'itd-api';
3684
+ *
3418
3685
  * const stream = itd.realtime();
3419
3686
  *
3420
- * stream.on('notification', ({ notification, unreadCount }) => {
3421
- * console.log(formatNotificationText(notification), unreadCount);
3687
+ * stream.onNotification(NotificationType.PostComment, async ({ update }) => {
3688
+ * await saveCommentNotification(update.data.notification);
3422
3689
  * });
3423
3690
  * stream.on('status', (status) => console.log('соединение:', status));
3424
3691
  *
3425
3692
  * await stream.connect();
3426
3693
  * // …позже
3427
3694
  * stream.disconnect();
3695
+ * await stream.drain();
3428
3696
  * ```
3429
3697
  */
3430
3698
  var ItdRealtime = class {
3431
3699
  #deps;
3432
3700
  #options;
3433
3701
  #emitter;
3702
+ #dispatcher;
3434
3703
  #transport;
3435
3704
  #maxAttempts;
3436
3705
  #controller;
@@ -3445,7 +3714,7 @@ var ItdRealtime = class {
3445
3714
  #wanted = false;
3446
3715
  #status = RealtimeStatus.Disconnected;
3447
3716
  #attempt = 0;
3448
- #timer;
3717
+ #cancelTimer;
3449
3718
  #detachEnvironment;
3450
3719
  constructor(deps, options = {}) {
3451
3720
  validateRealtimeOptions(options);
@@ -3458,6 +3727,14 @@ var ItdRealtime = class {
3458
3727
  if (deps.logger) deps.logger.error(message, error);
3459
3728
  else console.error(`[itd-api] ${message}`, error);
3460
3729
  });
3730
+ this.#dispatcher = new RealtimeDispatcher({
3731
+ concurrency: options.concurrency ?? 1,
3732
+ ...options.sequentialize ? { sequentialize: options.sequentialize } : {}
3733
+ }, {
3734
+ deliver: (context) => this.#deliver(context.update),
3735
+ middlewareError: (error, context) => this.#reportDispatchError("middlewareError", error, context),
3736
+ handlerError: (error, context) => this.#reportDispatchError("handlerError", error, context)
3737
+ });
3461
3738
  }
3462
3739
  /** Текущее состояние соединения. */
3463
3740
  get status() {
@@ -3488,6 +3765,40 @@ var ItdRealtime = class {
3488
3765
  return this.#emitter.once(event, listener);
3489
3766
  }
3490
3767
  /**
3768
+ * Добавляет промежуточный обработчик нормализованных обновлений.
3769
+ *
3770
+ * Обработчики выполняются в порядке регистрации. Если `next()` не вызван, обновление не
3771
+ * передаётся дальше по цепочке, асинхронным обработчикам и слушателям событий.
3772
+ *
3773
+ * @returns функция удаления обработчика
3774
+ */
3775
+ use(middleware) {
3776
+ if (typeof middleware !== "function") throw new require_storage.ItdConfigError("realtime.use() принимает функцию обработки");
3777
+ return this.#dispatcher.use(middleware);
3778
+ }
3779
+ onUpdate(selectorOrHandler, selectedHandler) {
3780
+ const selectAll = selectedHandler === void 0;
3781
+ const handler = selectAll ? selectorOrHandler : selectedHandler;
3782
+ if (typeof handler !== "function") throw new require_storage.ItdConfigError("realtime.onUpdate() принимает функцию обработчика");
3783
+ if (!selectAll && typeof selectorOrHandler !== "function" && !Object.values(RealtimeUpdateType).includes(selectorOrHandler)) throw new require_storage.ItdConfigError(`Неизвестный тип обновления потока: ${String(selectorOrHandler)}`);
3784
+ let predicate;
3785
+ if (selectAll) predicate = () => true;
3786
+ else {
3787
+ const selector = selectorOrHandler;
3788
+ predicate = typeof selector === "function" ? selector : (context) => context.update.type === selector;
3789
+ }
3790
+ return this.#dispatcher.on(predicate, handler);
3791
+ }
3792
+ onNotification(selector, handler) {
3793
+ if (typeof handler !== "function") throw new require_storage.ItdConfigError("realtime.onNotification() принимает функцию обработчика");
3794
+ if (typeof selector !== "function") validateNotificationSelector(selector);
3795
+ const predicate = (context) => {
3796
+ if (!isNotificationContext(context)) return false;
3797
+ return typeof selector === "function" ? selector(context) : matchesNotification(context, selector);
3798
+ };
3799
+ return this.#dispatcher.on(predicate, handler);
3800
+ }
3801
+ /**
3491
3802
  * Поднимает соединение.
3492
3803
  *
3493
3804
  * Повторный вызов при уже живом соединении ничего не делает — это защита от двойного
@@ -3498,9 +3809,14 @@ var ItdRealtime = class {
3498
3809
  async connect() {
3499
3810
  if (this.#wanted) return;
3500
3811
  this.#wanted = true;
3812
+ this.#deps.onConnect?.();
3501
3813
  this.#attachEnvironmentListeners();
3502
3814
  if (this.#options.syncCount !== false) try {
3503
- this.#emitter.emit("unreadCount", await this.#deps.fetchUnreadCount());
3815
+ const count = await this.#deps.fetchUnreadCount();
3816
+ if (this.#wanted) this.#dispatch({
3817
+ type: RealtimeUpdateType.UnreadCount,
3818
+ data: count
3819
+ }, void 0, RealtimeUpdateOrigin.Sync);
3504
3820
  } catch (error) {
3505
3821
  this.#deps.logger?.debug("не удалось получить число непрочитанных", error);
3506
3822
  }
@@ -3509,27 +3825,36 @@ var ItdRealtime = class {
3509
3825
  /** Закрывает соединение и отменяет запланированные попытки. */
3510
3826
  disconnect() {
3511
3827
  this.#wanted = false;
3512
- if (this.#timer !== void 0) {
3513
- clearTimeout(this.#timer);
3514
- this.#timer = void 0;
3828
+ if (this.#cancelTimer) {
3829
+ this.#cancelTimer();
3830
+ this.#cancelTimer = void 0;
3515
3831
  }
3516
3832
  this.#detachEnvironment?.();
3517
3833
  this.#detachEnvironment = void 0;
3518
3834
  this.#controller?.abort();
3519
3835
  this.#controller = void 0;
3520
3836
  this.#attempt = 0;
3837
+ this.#dispatcher.clearPending();
3521
3838
  this.#setStatus(RealtimeStatus.Disconnected);
3522
3839
  this.#deps.onClose?.();
3523
3840
  }
3524
- /** Снимает все подписки. Соединение при этом не закрывается. */
3841
+ /** Ждёт завершения всех принятых обновлений. */
3842
+ drain() {
3843
+ return this.#dispatcher.drain();
3844
+ }
3845
+ /** Снимает подписки `on()` и `once()`. Остальные обработчики остаются. */
3525
3846
  removeAllListeners() {
3526
3847
  this.#emitter.removeAllListeners();
3527
3848
  }
3528
3849
  #createTransport() {
3529
3850
  const kind = this.#options.transport ?? RealtimeTransportKind.Auto;
3530
3851
  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 } : {} });
3852
+ if (kind === RealtimeTransportKind.Poll || kind === RealtimeTransportKind.Auto && !require_runtime.supportsStreamingBody()) return new PollTransport({
3853
+ clock: this.#deps.clock ?? systemClock,
3854
+ ...this.#options.pollInterval !== void 0 ? { interval: this.#options.pollInterval } : {}
3855
+ });
3532
3856
  return new SseTransport({
3857
+ clock: this.#deps.clock ?? systemClock,
3533
3858
  ...this.#options.idleTimeout !== void 0 ? { idleTimeout: this.#options.idleTimeout } : {},
3534
3859
  ...this.#options.handshakeTimeout !== void 0 ? { handshakeTimeout: this.#options.handshakeTimeout } : {}
3535
3860
  });
@@ -3551,7 +3876,7 @@ var ItdRealtime = class {
3551
3876
  this.#attempt = 0;
3552
3877
  this.#setStatus(RealtimeStatus.Connected);
3553
3878
  },
3554
- onEvent: (event) => this.#handleEvent(event.name, event.data),
3879
+ onEvent: (event) => this.#handleEvent(event),
3555
3880
  onParseError: (error, raw) => this.#emitter.emit("parseError", {
3556
3881
  error,
3557
3882
  raw
@@ -3563,25 +3888,48 @@ var ItdRealtime = class {
3563
3888
  this.#handleFailure(error);
3564
3889
  });
3565
3890
  }
3566
- #handleEvent(name, data) {
3567
- this.#emitter.emit("message", {
3568
- name,
3569
- data
3891
+ #handleEvent(event) {
3892
+ if (!this.#wanted) return;
3893
+ this.#emitter.emit("message", event);
3894
+ if (event.name === "connected") {
3895
+ this.#emitter.emit("ready", { userId: pickString(event.data, "userId") });
3896
+ return;
3897
+ }
3898
+ const update = readRealtimeUpdate(event);
3899
+ if (update) this.#dispatch(update, event, RealtimeUpdateOrigin.Stream);
3900
+ }
3901
+ #dispatch(update, raw, origin) {
3902
+ this.#dispatcher.dispatch({
3903
+ update,
3904
+ stream: this,
3905
+ raw,
3906
+ origin
3570
3907
  });
3571
- if (name === "connected") {
3572
- this.#emitter.emit("ready", { userId: pickString(data, "userId") });
3908
+ }
3909
+ #deliver(update) {
3910
+ if (update.type === RealtimeUpdateType.Notification) {
3911
+ this.#emitter.emit("notification", update.data);
3912
+ if (update.data.unreadCount !== void 0) this.#emitter.emit("unreadCount", update.data.unreadCount);
3573
3913
  return;
3574
3914
  }
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);
3915
+ if (update.type === RealtimeUpdateType.UnreadCount) {
3916
+ this.#emitter.emit("unreadCount", update.data);
3579
3917
  return;
3580
3918
  }
3581
- if (name === "unread_count") {
3582
- const count = readUnreadCountEvent(data);
3583
- if (count !== void 0) this.#emitter.emit("unreadCount", count);
3919
+ if (update.type === RealtimeUpdateType.Unknown) return;
3920
+ assertNeverUpdate(update);
3921
+ }
3922
+ #reportDispatchError(event, error, context) {
3923
+ if (this.#emitter.listenerCount(event) > 0) {
3924
+ this.#emitter.emit(event, {
3925
+ error,
3926
+ context
3927
+ });
3928
+ return;
3584
3929
  }
3930
+ const message = event === "middlewareError" ? "Ошибка в промежуточном обработчике потока" : "Ошибка в обработчике обновления потока";
3931
+ if (this.#deps.logger) this.#deps.logger.error(message, error);
3932
+ else console.error(`[itd-api] ${message}`, error);
3585
3933
  }
3586
3934
  #handleFailure(error) {
3587
3935
  this.#controller = void 0;
@@ -3619,8 +3967,8 @@ var ItdRealtime = class {
3619
3967
  attempt: this.#attempt,
3620
3968
  delay
3621
3969
  });
3622
- this.#timer = setTimeout(() => {
3623
- this.#timer = void 0;
3970
+ this.#cancelTimer = (this.#deps.clock ?? systemClock).schedule(() => {
3971
+ this.#cancelTimer = void 0;
3624
3972
  this.#run();
3625
3973
  }, delay);
3626
3974
  }
@@ -3647,7 +3995,7 @@ var ItdRealtime = class {
3647
3995
  const target = globalThis;
3648
3996
  if (typeof target.addEventListener !== "function") return;
3649
3997
  const wake = () => {
3650
- if (this.#controller || this.#timer !== void 0) return;
3998
+ if (this.#controller || this.#cancelTimer) return;
3651
3999
  if (this.#status === RealtimeStatus.Disconnected) return;
3652
4000
  this.#attempt = 0;
3653
4001
  this.#run();
@@ -3669,6 +4017,9 @@ var ItdRealtime = class {
3669
4017
  this.#emitter.emit("status", status);
3670
4018
  }
3671
4019
  };
4020
+ function assertNeverUpdate(update) {
4021
+ throw new TypeError(`Необработанное обновление потока: ${String(update)}`);
4022
+ }
3672
4023
  //#endregion
3673
4024
  //#region src/core/pagination.ts
3674
4025
  /** Схема пагинации эндпоинта. */
@@ -4584,6 +4935,140 @@ var CommentsResource = class extends BaseResource {
4584
4935
  }
4585
4936
  };
4586
4937
  //#endregion
4938
+ //#region src/core/attachments/url-source.ts
4939
+ /** Убирает параметры MIME и приводит его к форме для сравнения. */
4940
+ function normalizeMimeType(contentType) {
4941
+ return contentType?.split(";", 1)[0]?.trim().toLowerCase() || void 0;
4942
+ }
4943
+ /** Достаёт имя файла из пути URL. */
4944
+ function filenameFromUrl(url) {
4945
+ const last = url.pathname.split("/").pop();
4946
+ if (!last) return void 0;
4947
+ try {
4948
+ return decodeURIComponent(last) || void 0;
4949
+ } catch {
4950
+ return last;
4951
+ }
4952
+ }
4953
+ /** Проверяет объявленный размер и возвращает его, если заголовок корректен. */
4954
+ function declaredSize(response, maxBytes, url) {
4955
+ const header = response.headers.get("content-length");
4956
+ if (header === null) return void 0;
4957
+ const size = Number(header);
4958
+ if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) return void 0;
4959
+ if (maxBytes !== void 0 && size > maxBytes) throw require_multi_storage.fileTooLarge(url, maxBytes, size);
4960
+ return size;
4961
+ }
4962
+ /** Получает HTTP-ответ источника и проверяет его статус. */
4963
+ async function fetchFile(target, options, context) {
4964
+ let requested;
4965
+ try {
4966
+ requested = new URL(target);
4967
+ } catch {
4968
+ throw new require_storage.ItdConfigError(`«${target}» не разбирается как адрес`);
4969
+ }
4970
+ if (requested.protocol !== "http:" && requested.protocol !== "https:") throw new require_storage.ItdConfigError(`вложение по адресу поддерживает только http и https, получено: ${requested.protocol}`);
4971
+ let response;
4972
+ try {
4973
+ response = await context.fetch(requested, { ...context.signal ? { signal: context.signal } : {} });
4974
+ } catch (error) {
4975
+ if (context.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
4976
+ throw new require_storage.ItdFileError(`не удалось получить файл по адресу ${requested.href}`, {
4977
+ reason: require_storage.ItdFileErrorReason.Network,
4978
+ url: requested.href,
4979
+ retryable: true,
4980
+ cause: error
4981
+ });
4982
+ }
4983
+ const finalUrl = response.url ? new URL(response.url) : requested;
4984
+ if (!response.ok) {
4985
+ await response.body?.cancel().catch(() => {});
4986
+ throw new require_storage.ItdFileError(`источник ${finalUrl.href} ответил статусом ${response.status}`, {
4987
+ reason: require_storage.ItdFileErrorReason.Http,
4988
+ url: finalUrl.href,
4989
+ status: response.status,
4990
+ retryable: response.status === 408 || response.status === 429 || response.status >= 500
4991
+ });
4992
+ }
4993
+ const { maxBytes } = require_multi_storage.resolveFileStreamOptions(options, require_multi_storage.DEFAULT_URL_FILE_MAX_BYTES);
4994
+ let size;
4995
+ try {
4996
+ size = declaredSize(response, maxBytes, finalUrl.href);
4997
+ } catch (error) {
4998
+ await response.body?.cancel().catch(() => {});
4999
+ throw error;
5000
+ }
5001
+ return {
5002
+ response,
5003
+ url: finalUrl,
5004
+ size
5005
+ };
5006
+ }
5007
+ /** Читает ответ с контролем размера до создания итогового `Blob`. */
5008
+ async function responseBlob(response, url, maxBytes, streamBufferBytes, signal) {
5009
+ if (!response.body) {
5010
+ const blob = await response.blob();
5011
+ if (maxBytes !== void 0 && blob.size > maxBytes) throw require_multi_storage.fileTooLarge(url, maxBytes, blob.size);
5012
+ return blob;
5013
+ }
5014
+ const chunks = [];
5015
+ const reader = require_multi_storage.boundedFileStream(response.body, {
5016
+ ...maxBytes !== void 0 ? { maxBytes } : {},
5017
+ streamBufferBytes,
5018
+ ...signal ? { signal } : {},
5019
+ url,
5020
+ retryableRead: true
5021
+ }).getReader();
5022
+ try {
5023
+ for (;;) {
5024
+ const next = await reader.read();
5025
+ if (next.done) break;
5026
+ chunks.push(next.value);
5027
+ }
5028
+ } finally {
5029
+ reader.releaseLock();
5030
+ }
5031
+ return new Blob(chunks.map((chunk) => Uint8Array.from(chunk).buffer));
5032
+ }
5033
+ /** Скачивает файл целиком с ограничением размера. @internal */
5034
+ async function downloadFile(target, options, context) {
5035
+ const resolved = require_multi_storage.resolveFileStreamOptions(options, require_multi_storage.DEFAULT_URL_FILE_MAX_BYTES);
5036
+ const { response, url } = await fetchFile(target, options, context);
5037
+ const blob = await responseBlob(response, url.href, resolved.maxBytes, resolved.streamBufferBytes, context.signal);
5038
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
5039
+ const filename = options.filename ?? filenameFromUrl(url);
5040
+ return {
5041
+ file: new Blob([blob], { type: contentType ?? "" }),
5042
+ ...filename ? { filename } : {},
5043
+ ...contentType ? { contentType } : {}
5044
+ };
5045
+ }
5046
+ /** Открывает HTTP-ответ как ограниченный поток. @internal */
5047
+ async function openUrlFile(target, options, context) {
5048
+ const resolved = require_multi_storage.resolveFileStreamOptions(options, require_multi_storage.DEFAULT_URL_FILE_MAX_BYTES);
5049
+ const { response, url, size } = await fetchFile(target, options, context);
5050
+ if (!response.body) throw new require_storage.ItdFileError(`источник ${url.href} не предоставил потоковое тело`, {
5051
+ reason: require_storage.ItdFileErrorReason.StreamUnavailable,
5052
+ url: url.href
5053
+ });
5054
+ const stream = require_multi_storage.boundedFileStream(response.body, {
5055
+ ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
5056
+ streamBufferBytes: resolved.streamBufferBytes,
5057
+ ...context.signal ? { signal: context.signal } : {},
5058
+ url: url.href,
5059
+ retryableRead: true
5060
+ });
5061
+ const filename = options.filename ?? filenameFromUrl(url);
5062
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
5063
+ return {
5064
+ stream,
5065
+ ...filename ? { filename } : {},
5066
+ ...contentType ? { contentType } : {},
5067
+ ...size !== void 0 ? { size } : {},
5068
+ close: () => stream.cancel().catch(() => {})
5069
+ };
5070
+ }
5071
+ //#endregion
4587
5072
  //#region src/core/mime.ts
4588
5073
  /** Изображения, которые принимает `POST /api/files/upload`. */
4589
5074
  const IMAGE_MIME_TYPES = Object.freeze([
@@ -4832,7 +5317,7 @@ var FilesResource = class extends BaseResource {
4832
5317
  async #prepareBuffer(input, options, context) {
4833
5318
  const content = await this.#resolveBuffer(input, context);
4834
5319
  const filename = options.filename ?? content.filename ?? this.#nameFromMime(options.contentType ?? content.contentType);
4835
- const contentType = require_multi_storage.normalizeMimeType(options.contentType ?? content.contentType) ?? require_multi_storage.normalizeMimeType(require_runtime.isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
5320
+ const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(require_runtime.isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
4836
5321
  if (options.validateMime !== false) assertAllowedMime(contentType, filename);
4837
5322
  const blob = require_runtime.isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
4838
5323
  const limits = require_multi_storage.resolveFileStreamOptions(options);
@@ -4853,7 +5338,7 @@ var FilesResource = class extends BaseResource {
4853
5338
  if (typeof input === "object" && input !== null && "open" in input) opened = await input.open(context);
4854
5339
  else if (typeof input === "object" && input !== null && "url" in input) {
4855
5340
  const { url, ...urlOptions } = input;
4856
- opened = await require_multi_storage.openUrlFile(url, urlOptions, context);
5341
+ opened = await openUrlFile(url, urlOptions, context);
4857
5342
  } else throw new require_storage.ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
4858
5343
  if (!opened || typeof ReadableStream === "undefined" || !(opened.stream instanceof ReadableStream)) {
4859
5344
  await opened?.close?.();
@@ -4861,7 +5346,7 @@ var FilesResource = class extends BaseResource {
4861
5346
  }
4862
5347
  try {
4863
5348
  const filename = options.filename ?? opened.filename ?? this.#nameFromMime(options.contentType ?? opened.contentType);
4864
- const contentType = require_multi_storage.normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
5349
+ const contentType = normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
4865
5350
  if (options.validateMime !== false) assertAllowedMime(contentType, filename);
4866
5351
  const limits = require_multi_storage.resolveFileStreamOptions({
4867
5352
  mode: require_multi_storage.FileTransferMode.Stream,
@@ -4899,7 +5384,7 @@ var FilesResource = class extends BaseResource {
4899
5384
  if ("load" in input) return input.load(context);
4900
5385
  if ("url" in input) {
4901
5386
  const { url, ...urlOptions } = input;
4902
- return require_multi_storage.downloadFile(url, urlOptions, context);
5387
+ return downloadFile(url, urlOptions, context);
4903
5388
  }
4904
5389
  if ("file" in input) return input;
4905
5390
  if ("open" in input) throw new require_storage.ItdConfigError("потоковый источник нельзя использовать как буферный");
@@ -4907,7 +5392,7 @@ var FilesResource = class extends BaseResource {
4907
5392
  }
4908
5393
  /** Подбирает непустое имя, обязательное для multipart. */
4909
5394
  #nameFromMime(contentType) {
4910
- const extension = require_multi_storage.normalizeMimeType(contentType)?.split("/")[1];
5395
+ const extension = normalizeMimeType(contentType)?.split("/")[1];
4911
5396
  return extension ? `file.${extension}` : "file";
4912
5397
  }
4913
5398
  };
@@ -5129,7 +5614,6 @@ var NotificationsResource = class extends BaseResource {
5129
5614
  };
5130
5615
  //#endregion
5131
5616
  //#region src/core/time.ts
5132
- /** Отметка времени без часового пояса: `2026-07-23 23:14:25`, возможно с долями секунды. */
5133
5617
  const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
5134
5618
  /**
5135
5619
  * Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC.
@@ -5148,6 +5632,21 @@ function utcStampToIso(value) {
5148
5632
  const iso = `${match[1]}T${match[2]}Z`;
5149
5633
  return Number.isFinite(Date.parse(iso)) ? iso : value;
5150
5634
  }
5635
+ /**
5636
+ * Разбирает дату API в объект `Date`.
5637
+ *
5638
+ * @returns `null`, если строки нет или она не разбирается
5639
+ *
5640
+ * @example
5641
+ * ```ts
5642
+ * const created = toDate(post.createdAt);
5643
+ * ```
5644
+ */
5645
+ function toDate(value) {
5646
+ if (!value) return null;
5647
+ const date = new Date(value);
5648
+ return Number.isFinite(date.getTime()) ? date : null;
5649
+ }
5151
5650
  //#endregion
5152
5651
  //#region src/resources/platform.ts
5153
5652
  /** Приводит `last_checked` каждого сервиса к ISO. Остальное остаётся как прислал сервер. */
@@ -6078,7 +6577,7 @@ function resolvePoll(input) {
6078
6577
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6079
6578
  const BUILD_UPDATE = Symbol.for("itd.postBuilder.update");
6080
6579
  /**
6081
- * Проверяет данные поста.
6580
+ * Проверяет и нормализует данные поста.
6082
6581
  *
6083
6582
  * Отдельного внимания заслуживает `wallRecipientId`: API принимает там **только UUID**,
6084
6583
  * а имя пользователя молча приводит к ошибке на сервере. Проверка здесь превращает
@@ -6098,10 +6597,11 @@ function validatePost(input) {
6098
6597
  if (wallRecipientId !== void 0 && wallRecipientId !== null) {
6099
6598
  if (!UUID_PATTERN.test(wallRecipientId)) throw new require_storage.ItdConfigError(`wallRecipientId должен быть UUID, а не именем пользователя (получено: «${wallRecipientId}»). Идентификатор можно взять из профиля: (await itd.users.get(username)).id`);
6100
6599
  }
6600
+ const { poll: inputPoll, ...data } = input;
6101
6601
  return {
6102
- ...input,
6602
+ ...data,
6103
6603
  ...input.spans !== void 0 ? { spans: validateSpans(content, input.spans) } : {},
6104
- ...input.poll !== void 0 ? { poll: resolvePoll(input.poll) } : {}
6604
+ ...inputPoll !== void 0 ? { poll: resolvePoll(inputPoll) } : {}
6105
6605
  };
6106
6606
  }
6107
6607
  /**
@@ -6298,7 +6798,8 @@ function post(content) {
6298
6798
  }
6299
6799
  /** Приводит любую форму входа к готовым данным поста. */
6300
6800
  function resolvePost(input) {
6301
- return resolveInput(input, () => post(), validatePost);
6801
+ const resolved = typeof input === "function" ? input(post()) : input;
6802
+ return isBuilder(resolved) ? validatePost(resolved.build()) : validatePost(resolved);
6302
6803
  }
6303
6804
  function validatePostUpdate(input) {
6304
6805
  if (!input || typeof input !== "object") throw new require_storage.ItdConfigError("Для обновления поста нужен объект с явно заданным content");
@@ -7573,7 +8074,7 @@ var ItdClient = class ItdClient {
7573
8074
  for (const service of BUILT_IN_SERVICES) this.#services.define(service);
7574
8075
  for (const service of config.services) this.#services.define(service);
7575
8076
  const shared = config.rateLimit ? internals.queues : void 0;
7576
- const queues = shared ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit) : void 0);
8077
+ const queues = shared ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit, config.clock) : void 0);
7577
8078
  this.#queues = queues;
7578
8079
  this.#ownsQueues = shared === void 0;
7579
8080
  let authManager;
@@ -7589,6 +8090,7 @@ var ItdClient = class ItdClient {
7589
8090
  this.#transport = transport;
7590
8091
  const pluginsLayer = createPluginsMiddleware(this.#plugins);
7591
8092
  const retriesLayer = createRetryMiddleware({
8093
+ clock: config.clock,
7592
8094
  retry: config.retry,
7593
8095
  rateLimitDelays: config.rateLimit?.retryDelays ?? [],
7594
8096
  pauseQueue: queues ? (ms, request) => queues.for(request.service).pause(ms) : void 0,
@@ -7768,10 +8270,12 @@ var ItdClient = class ItdClient {
7768
8270
  *
7769
8271
  * @example
7770
8272
  * ```ts
8273
+ * import { NotificationType } from 'itd-api';
8274
+ *
7771
8275
  * const stream = itd.realtime();
7772
8276
  *
7773
- * stream.on('notification', ({ notification }) => {
7774
- * console.log(formatNotificationText(notification));
8277
+ * stream.onNotification(NotificationType.PostComment, async ({ update }) => {
8278
+ * await handleComment(update.data.notification);
7775
8279
  * });
7776
8280
  * stream.on('unreadCount', (count) => setBadge(count));
7777
8281
  *
@@ -7789,8 +8293,10 @@ var ItdClient = class ItdClient {
7789
8293
  getToken: () => this.#authManager.getAccessToken(),
7790
8294
  refresh: () => this.#authManager.onUnauthorized(),
7791
8295
  fetchUnreadCount: () => this.notifications.count(),
8296
+ onConnect: () => this.#streams.add(stream),
7792
8297
  onClose: () => this.#streams.delete(stream),
7793
- logger: this.#config.logger
8298
+ logger: this.#config.logger,
8299
+ clock: this.#config.clock
7794
8300
  }, options);
7795
8301
  this.#streams.add(stream);
7796
8302
  return stream;
@@ -7799,8 +8305,8 @@ var ItdClient = class ItdClient {
7799
8305
  * Освобождает ресурсы клиента: закрывает все потоки уведомлений, отправляет открытые
7800
8306
  * накопители {@link telemetry}, затем останавливает очередь запросов.
7801
8307
  *
7802
- * После вызова клиентом можно пользоваться снова — новые запросы поднимут всё заново,
7803
- * но уже созданные потоки и успешно закрытые накопители останутся закрытыми.
8308
+ * Метод дожидается активных обработчиков потока. После вызова клиентом можно пользоваться
8309
+ * снова; ранее созданный поток можно запустить повторным `connect()`.
7804
8310
  *
7805
8311
  * Общая очередь, полученная от {@link ItdAccounts}, не останавливается: её гасит сам
7806
8312
  * контейнер, когда закрывает все аккаунты разом.
@@ -7813,8 +8319,9 @@ var ItdClient = class ItdClient {
7813
8319
  * ```
7814
8320
  */
7815
8321
  async close() {
7816
- this.#disconnectStreams();
8322
+ const streams = this.#disconnectStreams();
7817
8323
  try {
8324
+ await Promise.all(streams.map((stream) => stream.drain()));
7818
8325
  await this.telemetry.close();
7819
8326
  } finally {
7820
8327
  if (this.#ownsQueues) this.#queues?.stop();
@@ -7833,8 +8340,10 @@ var ItdClient = class ItdClient {
7833
8340
  }
7834
8341
  /** Завершает потоки до того, как запросы начнут использовать другой аккаунт. */
7835
8342
  #disconnectStreams() {
7836
- for (const stream of [...this.#streams]) stream.disconnect();
8343
+ const streams = [...this.#streams];
8344
+ for (const stream of streams) stream.disconnect();
7837
8345
  this.#streams.clear();
8346
+ return streams;
7838
8347
  }
7839
8348
  /** Позволяет использовать клиент с `await using`. */
7840
8349
  [Symbol.asyncDispose]() {
@@ -7973,7 +8482,7 @@ var ItdAccounts = class ItdAccounts {
7973
8482
  this.#plugins = orderPluginDefinitions(plugins ?? []);
7974
8483
  this.#rateLimitScope = rateLimitScope ?? "account";
7975
8484
  const rateLimit = this.#rateLimitScope === "shared" ? resolveRateLimit(base.rateLimit) : void 0;
7976
- this.#queues = rateLimit ? new RequestQueuePool(rateLimit) : void 0;
8485
+ this.#queues = rateLimit ? new RequestQueuePool(rateLimit, base.clock ?? systemClock) : void 0;
7977
8486
  const logger = typeof base.logger === "object" ? base.logger : void 0;
7978
8487
  this.#logger = logger;
7979
8488
  this.#emitter = new Emitter((error) => reportListenerError(logger, "аккаунтов", error));
@@ -8302,6 +8811,92 @@ function createAccounts(options = {}) {
8302
8811
  return new ItdAccounts(options);
8303
8812
  }
8304
8813
  //#endregion
8814
+ //#region src/core/attachments/factories.ts
8815
+ function fromUrl(url, options = {}) {
8816
+ if (require_multi_storage.resolveFileStreamOptions(options, 104857600).mode === require_multi_storage.FileTransferMode.Stream) return { open: (context) => openUrlFile(url, options, context) };
8817
+ return { load: (context) => downloadFile(url, options, context) };
8818
+ }
8819
+ /**
8820
+ * Создаёт повторяемый пользовательский поток.
8821
+ *
8822
+ * Фабрика вызывается заново для каждой попытки; возвращать один и тот же поток нельзя.
8823
+ */
8824
+ function fromStream(factory, options = {}) {
8825
+ const resolved = require_multi_storage.resolveFileStreamOptions({
8826
+ ...options,
8827
+ mode: require_multi_storage.FileTransferMode.Stream
8828
+ }, void 0);
8829
+ require_multi_storage.optionalBytes(options.size, "size");
8830
+ return { open: async (context) => {
8831
+ let opened;
8832
+ try {
8833
+ opened = await factory(context);
8834
+ } catch (error) {
8835
+ if (error instanceof require_storage.ItdFileError || error instanceof require_storage.ItdConfigError || context.signal?.aborted) throw error;
8836
+ throw new require_storage.ItdFileError("не удалось открыть поток вложения", {
8837
+ reason: require_storage.ItdFileErrorReason.Read,
8838
+ retryable: true,
8839
+ cause: error
8840
+ });
8841
+ }
8842
+ const content = require_multi_storage.isReadableByteStream(opened) ? { stream: opened } : opened;
8843
+ if (!content || !require_multi_storage.isReadableByteStream(content.stream)) throw new require_storage.ItdConfigError("fromStream должен вернуть ReadableStream или { stream }");
8844
+ const size = options.size ?? content.size;
8845
+ require_multi_storage.optionalBytes(size, "size");
8846
+ if (resolved.maxBytes !== void 0 && size !== void 0 && size > resolved.maxBytes) {
8847
+ await content.close?.();
8848
+ throw require_multi_storage.fileTooLarge(void 0, resolved.maxBytes, size);
8849
+ }
8850
+ const contentType = normalizeMimeType(options.contentType ?? content.contentType);
8851
+ return {
8852
+ stream: require_multi_storage.boundedFileStream(content.stream, {
8853
+ ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
8854
+ streamBufferBytes: resolved.streamBufferBytes,
8855
+ ...context.signal ? { signal: context.signal } : {},
8856
+ retryableRead: true
8857
+ }),
8858
+ ...options.filename ?? content.filename ? { filename: options.filename ?? content.filename } : {},
8859
+ ...contentType ? { contentType } : {},
8860
+ ...size !== void 0 ? { size } : {},
8861
+ ...content.close ? { close: content.close } : {}
8862
+ };
8863
+ } };
8864
+ }
8865
+ //#endregion
8866
+ //#region src/models/guards.ts
8867
+ /**
8868
+ * Свой ли это профиль.
8869
+ *
8870
+ * @example
8871
+ * ```ts
8872
+ * if (isMyProfile(profile)) console.log(profile.subscription.isActive);
8873
+ * ```
8874
+ */
8875
+ function isMyProfile(profile) {
8876
+ return "subscription" in profile;
8877
+ }
8878
+ //#endregion
8879
+ //#region src/models/status-helpers.ts
8880
+ const STATUS_WINDOW_DAYS = 90;
8881
+ /**
8882
+ * Разворачивает историю сервиса в массив на 90 суток.
8883
+ * Сутки без данных становятся `null`.
8884
+ *
8885
+ * @returns массив, где индекс — сколько суток назад: `[0]` — сегодня
8886
+ *
8887
+ * @example
8888
+ * ```ts
8889
+ * const status = await itd.platform.status();
8890
+ * const days = statusDays(status.services[0]);
8891
+ *
8892
+ * days[0]?.uptime; // доступность за сегодня
8893
+ * days.filter((day) => day === null).length; // за сколько суток данных нет
8894
+ * ```
8895
+ */
8896
+ function statusDays(service) {
8897
+ return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
8898
+ }
8899
+ //#endregion
8305
8900
  //#region src/notifications/text.ts
8306
8901
  /** Имя, которое подставляется, если участник неизвестен. */
8307
8902
  const UNKNOWN_ACTOR = "Пользователь";
@@ -8433,6 +9028,87 @@ function resolveNotificationUrl(notification) {
8433
9028
  return clickUrl || "/notifications";
8434
9029
  }
8435
9030
  //#endregion
9031
+ //#region src/realtime/router.ts
9032
+ /**
9033
+ * Направляет обновления потока в именованные цепочки промежуточных обработчиков.
9034
+ *
9035
+ * @example
9036
+ * ```ts
9037
+ * import { RealtimeRouter, RealtimeUpdateType } from 'itd-api';
9038
+ *
9039
+ * const router = new RealtimeRouter((context) => context.update.type);
9040
+ * router.route(RealtimeUpdateType.Notification, async (context, next) => {
9041
+ * if (context.update.type === RealtimeUpdateType.Notification) {
9042
+ * await handleNotification(context.update.data.notification);
9043
+ * }
9044
+ * await next();
9045
+ * });
9046
+ * stream.use(router.middleware());
9047
+ * ```
9048
+ */
9049
+ var RealtimeRouter = class {
9050
+ #selector;
9051
+ #routes = /* @__PURE__ */ new Map();
9052
+ #fallback = [];
9053
+ constructor(selector) {
9054
+ if (typeof selector !== "function") throw new require_storage.ItdConfigError("RealtimeRouter принимает функцию выбора маршрута");
9055
+ this.#selector = selector;
9056
+ }
9057
+ /** Добавляет промежуточные обработчики к маршруту и возвращает функцию их удаления. */
9058
+ route(key, ...middleware) {
9059
+ if (!isPropertyKey(key)) throw new require_storage.ItdConfigError("Ключ realtime route должен быть PropertyKey");
9060
+ const registration = this.#registration(middleware);
9061
+ const registrations = this.#routes.get(key) ?? [];
9062
+ registrations.push(registration);
9063
+ this.#routes.set(key, registrations);
9064
+ return () => {
9065
+ const current = this.#routes.get(key);
9066
+ if (!current) return;
9067
+ const index = current.indexOf(registration);
9068
+ if (index >= 0) current.splice(index, 1);
9069
+ if (current.length === 0) this.#routes.delete(key);
9070
+ };
9071
+ }
9072
+ /** Добавляет промежуточные обработчики для обновлений без зарегистрированного маршрута. */
9073
+ otherwise(...middleware) {
9074
+ const registration = this.#registration(middleware);
9075
+ this.#fallback.push(registration);
9076
+ return () => {
9077
+ const index = this.#fallback.indexOf(registration);
9078
+ if (index >= 0) this.#fallback.splice(index, 1);
9079
+ };
9080
+ }
9081
+ /** Возвращает промежуточный обработчик для `stream.use()`. */
9082
+ middleware() {
9083
+ const middleware = (context, next) => this.#captureMiddleware()(context, next);
9084
+ return withRealtimeMiddlewareSnapshot(middleware, () => this.#captureMiddleware());
9085
+ }
9086
+ #captureMiddleware() {
9087
+ const routes = /* @__PURE__ */ new Map();
9088
+ for (const [key, registrations] of this.#routes) routes.set(key, registrations.flatMap(({ middleware }) => middleware).map(captureRealtimeMiddleware));
9089
+ const fallback = this.#fallback.flatMap(({ middleware }) => middleware).map(captureRealtimeMiddleware);
9090
+ return async (context, next) => {
9091
+ const key = await this.#selector(context);
9092
+ if (key != null && !isPropertyKey(key)) throw new require_storage.ItdConfigError("Функция выбора маршрута должна возвращать PropertyKey, null или undefined");
9093
+ const route = key == null ? void 0 : routes.get(key);
9094
+ const chain = route && route.length > 0 ? route : fallback;
9095
+ if (chain.length === 0) {
9096
+ await next();
9097
+ return;
9098
+ }
9099
+ await runRealtimeMiddleware(chain, context, next);
9100
+ };
9101
+ }
9102
+ #registration(middleware) {
9103
+ if (middleware.length === 0) throw new require_storage.ItdConfigError("Маршрут должен содержать хотя бы один обработчик");
9104
+ for (const item of middleware) if (typeof item !== "function") throw new require_storage.ItdConfigError("Маршрут принимает только функции обработки");
9105
+ return { middleware: [...middleware] };
9106
+ }
9107
+ };
9108
+ function isPropertyKey(value) {
9109
+ return typeof value === "string" || typeof value === "number" || typeof value === "symbol";
9110
+ }
9111
+ //#endregion
8436
9112
  //#region src/spans/render.ts
8437
9113
  /** Формат результата {@link renderSpans}. */
8438
9114
  const SpanRenderFormat = Object.freeze({
@@ -8637,54 +9313,6 @@ function renderSpans(content, spans = [], options = {}) {
8637
9313
  return result;
8638
9314
  }
8639
9315
  //#endregion
8640
- //#region src/types/models.ts
8641
- /**
8642
- * Свой ли это профиль.
8643
- *
8644
- * @example
8645
- * ```ts
8646
- * if (isMyProfile(profile)) console.log(profile.subscription.isActive);
8647
- * ```
8648
- */
8649
- function isMyProfile(profile) {
8650
- return "subscription" in profile;
8651
- }
8652
- /** Глубина истории статуса в сутках. Столько элементов отдаёт {@link statusDays}. */
8653
- const STATUS_WINDOW_DAYS = 90;
8654
- /**
8655
- * Разбирает дату API в объект `Date`.
8656
- *
8657
- * @returns `null`, если строки нет или она не разбирается
8658
- *
8659
- * @example
8660
- * ```ts
8661
- * const created = toDate(post.createdAt);
8662
- * ```
8663
- */
8664
- function toDate(value) {
8665
- if (!value) return null;
8666
- const date = new Date(value);
8667
- return Number.isFinite(date.getTime()) ? date : null;
8668
- }
8669
- /**
8670
- * Разворачивает историю сервиса в массив на 90 суток.
8671
- * Сутки без данных становятся `null`.
8672
- *
8673
- * @returns массив, где индекс — сколько суток назад: `[0]` — сегодня
8674
- *
8675
- * @example
8676
- * ```ts
8677
- * const status = await itd.platform.status();
8678
- * const days = statusDays(status.services[0]);
8679
- *
8680
- * days[0]?.uptime; // доступность за сегодня
8681
- * days.filter((day) => day === null).length; // за сколько суток данных нет
8682
- * ```
8683
- */
8684
- function statusDays(service) {
8685
- return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
8686
- }
8687
- //#endregion
8688
9316
  exports.ALLOWED_MIME_TYPES = ALLOWED_MIME_TYPES;
8689
9317
  exports.AUDIO_MIME_TYPES = AUDIO_MIME_TYPES;
8690
9318
  exports.AUTH_FLAG_COOKIE = require_multi_storage.AUTH_FLAG_COOKIE;
@@ -8743,8 +9371,11 @@ exports.RECONNECT_JITTER = RECONNECT_JITTER;
8743
9371
  exports.REFRESH_COOKIE = require_multi_storage.REFRESH_COOKIE;
8744
9372
  exports.REFRESH_COOKIE_PATH = require_multi_storage.REFRESH_COOKIE_PATH;
8745
9373
  exports.REQUEST_OPTION_KEYS = REQUEST_OPTION_KEYS;
9374
+ exports.RealtimeRouter = RealtimeRouter;
8746
9375
  exports.RealtimeStatus = RealtimeStatus;
8747
9376
  exports.RealtimeTransportKind = RealtimeTransportKind;
9377
+ exports.RealtimeUpdateOrigin = RealtimeUpdateOrigin;
9378
+ exports.RealtimeUpdateType = RealtimeUpdateType;
8748
9379
  exports.ReportReason = ReportReason;
8749
9380
  exports.ReportTargetType = ReportTargetType;
8750
9381
  exports.RuntimeMode = require_runtime.RuntimeMode;
@@ -8770,8 +9401,8 @@ exports.createMultiTokenStorage = require_multi_storage.createMultiTokenStorage;
8770
9401
  exports.createRecordMultiStorage = require_multi_storage.createRecordMultiStorage;
8771
9402
  exports.createTokenStorage = require_storage.createTokenStorage;
8772
9403
  exports.formatNotificationText = formatNotificationText;
8773
- exports.fromStream = require_multi_storage.fromStream;
8774
- exports.fromUrl = require_multi_storage.fromUrl;
9404
+ exports.fromStream = fromStream;
9405
+ exports.fromUrl = fromUrl;
8775
9406
  exports.isBuilder = isBuilder;
8776
9407
  exports.isItdApiError = require_storage.isItdApiError;
8777
9408
  exports.isItdAuthError = require_storage.isItdAuthError;
@@ -8800,6 +9431,7 @@ exports.report = report;
8800
9431
  exports.resolveNotificationUrl = resolveNotificationUrl;
8801
9432
  exports.scopedTokenStorage = require_multi_storage.scopedTokenStorage;
8802
9433
  exports.statusDays = statusDays;
9434
+ exports.systemClock = systemClock;
8803
9435
  exports.toDate = toDate;
8804
9436
  exports.utcStampToIso = utcStampToIso;
8805
9437