itd-api 0.0.7 → 0.0.9

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.
@@ -616,6 +616,20 @@ var RealtimeStatus = Object.freeze({
616
616
  Error: "error",
617
617
  Disconnected: "disconnected"
618
618
  });
619
+ var ServiceState = Object.freeze({
620
+ /** Работает штатно. */
621
+ Operational: "operational",
622
+ /** Работает с деградацией. */
623
+ Degraded: "degraded",
624
+ /** Недоступен. */
625
+ Downtime: "downtime"
626
+ });
627
+ var IncidentKind = Object.freeze({
628
+ /** Недоступен. */
629
+ Down: "down",
630
+ /** Деградация. */
631
+ Degraded: "deg"
632
+ });
619
633
  var AccessType = Object.freeze({
620
634
  /** Никто. */
621
635
  Nobody: "nobody",
@@ -1285,8 +1299,18 @@ function hasLocalStorage() {
1285
1299
 
1286
1300
  // src/core/auth.ts
1287
1301
  var AUTH_PATHS = {
1302
+ signUp: "/api/v1/auth/sign-up",
1288
1303
  signIn: "/api/v1/auth/sign-in",
1289
- refresh: "/api/v1/auth/refresh"
1304
+ verifyOtp: "/api/v1/auth/verify-otp",
1305
+ resendOtp: "/api/v1/auth/resend-otp",
1306
+ refresh: "/api/v1/auth/refresh",
1307
+ logout: "/api/v1/auth/logout",
1308
+ forgotPassword: "/api/v1/auth/forgot-password",
1309
+ resetPassword: "/api/v1/auth/reset-password",
1310
+ changePassword: "/api/v1/auth/change-password",
1311
+ sessions: "/api/v1/auth/sessions",
1312
+ /** Префикс внешнего входа: к нему дописывается имя провайдера. */
1313
+ oauthLogin: "/api/v1/auth/login"
1290
1314
  };
1291
1315
  var TURNSTILE_SITE_KEY = "0x4AAAAAACHhxczw6fJGwPBg";
1292
1316
  var DEVICE_ID_HEADER = "X-Device-Id";
@@ -1295,13 +1319,23 @@ function readAccessToken(payload) {
1295
1319
  const token = payload.accessToken;
1296
1320
  return typeof token === "string" && token.length > 0 ? token : void 0;
1297
1321
  }
1322
+ function reportListenerError(logger, scope, error) {
1323
+ const message = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0432 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0447\u0438\u043A\u0435 \u0441\u043E\u0431\u044B\u0442\u0438\u044F ${scope}`;
1324
+ if (logger) logger.error(message, error);
1325
+ else console.error(`[itd-api] ${message}`, error);
1326
+ }
1298
1327
  var AuthManager = class {
1299
1328
  #config;
1300
- #http;
1329
+ #send;
1301
1330
  #jar;
1302
- #emitter = new Emitter();
1331
+ #emitter;
1303
1332
  /** `undefined` — сессия ещё не читалась из хранилища. */
1304
1333
  #session;
1334
+ /**
1335
+ * Общий промис чтения сессии из хранилища. Дедупликация: параллельные запросы на холодном
1336
+ * старте читают хранилище один раз и не заводят каждый свой `deviceId`.
1337
+ */
1338
+ #loading = null;
1305
1339
  /** Общий промис обновления: к нему присоединяются все, кто получил 401. */
1306
1340
  #refreshing = null;
1307
1341
  /** Общий промис входа по логину и паролю. */
@@ -1313,10 +1347,15 @@ var AuthManager = class {
1313
1347
  * поэтому `clear()` его не трогает.
1314
1348
  */
1315
1349
  #deviceId;
1316
- constructor(config, http, jar) {
1350
+ /** Общий промис первичной выдачи `deviceId` — чтобы параллельные запросы получили один. */
1351
+ #deviceIdLoading = null;
1352
+ constructor(config, send, jar) {
1317
1353
  this.#config = config;
1318
- this.#http = http;
1354
+ this.#send = send;
1319
1355
  this.#jar = jar;
1356
+ this.#emitter = new Emitter(
1357
+ (error) => reportListenerError(config.logger, "\u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0438", error)
1358
+ );
1320
1359
  }
1321
1360
  /** Подписка на события авторизации. */
1322
1361
  get on() {
@@ -1343,7 +1382,7 @@ var AuthManager = class {
1343
1382
  /** То же самое, но без чтения хранилища — для вызовов, где сессия уже загружена. */
1344
1383
  #hasRefreshSession() {
1345
1384
  if (!this.#config.useCookieJar) return true;
1346
- if (this.#jar.has(AUTH_FLAG_COOKIE)) return true;
1385
+ if (this.#jar.has(AUTH_FLAG_COOKIE, this.#config.baseUrl)) return true;
1347
1386
  return Boolean(this.#session?.refreshToken);
1348
1387
  }
1349
1388
  /** Заголовки авторизации для очередного запроса. Пустой объект, если токена нет. */
@@ -1358,8 +1397,14 @@ var AuthManager = class {
1358
1397
  * сервер связывает с ним запись в списке сессий, и плавающее значение плодило бы
1359
1398
  * по новой сессии на каждый старт.
1360
1399
  */
1361
- async getDeviceId() {
1362
- if (this.#deviceId) return this.#deviceId;
1400
+ getDeviceId() {
1401
+ if (this.#deviceId) return Promise.resolve(this.#deviceId);
1402
+ this.#deviceIdLoading ??= this.#resolveDeviceId().finally(() => {
1403
+ this.#deviceIdLoading = null;
1404
+ });
1405
+ return this.#deviceIdLoading;
1406
+ }
1407
+ async #resolveDeviceId() {
1363
1408
  const session = await this.#loadSession();
1364
1409
  const deviceId = this.#config.deviceId ?? session?.deviceId ?? createDeviceId();
1365
1410
  this.#deviceId = deviceId;
@@ -1442,12 +1487,14 @@ var AuthManager = class {
1442
1487
  async getSession() {
1443
1488
  return this.#loadSession();
1444
1489
  }
1445
- /** Заменяет сессию целиком. */
1490
+ /** Заменяет сессию и связанные с ней cookie целиком. */
1446
1491
  async setSession(session) {
1492
+ this.#jar.clear();
1447
1493
  this.#jar.deserialize(session.cookies);
1448
- this.#deviceId ??= session.deviceId;
1449
- await this.#saveSession(session);
1494
+ if (session.deviceId) this.#deviceId = session.deviceId;
1495
+ this.#session = session;
1450
1496
  this.#seedRefreshCookie();
1497
+ await this.#saveSession(session);
1451
1498
  }
1452
1499
  /**
1453
1500
  * Забывает сессию и cookie. Сетевой запрос не выполняется.
@@ -1462,8 +1509,14 @@ var AuthManager = class {
1462
1509
  if (this.#deviceId) await this.#saveSession({ deviceId: this.#deviceId });
1463
1510
  this.#emitter.emit("signOut", void 0);
1464
1511
  }
1465
- async #loadSession() {
1466
- if (this.#session !== void 0) return this.#session;
1512
+ #loadSession() {
1513
+ if (this.#session !== void 0) return Promise.resolve(this.#session);
1514
+ this.#loading ??= this.#performLoad().finally(() => {
1515
+ this.#loading = null;
1516
+ });
1517
+ return this.#loading;
1518
+ }
1519
+ async #performLoad() {
1467
1520
  const stored = await this.#config.storage.get() ?? null;
1468
1521
  if (stored?.cookies) this.#jar.deserialize(stored.cookies);
1469
1522
  const fromConfig = this.#sessionFromConfig(this.#config.auth);
@@ -1531,17 +1584,14 @@ var AuthManager = class {
1531
1584
  return this.#reloginOrNull();
1532
1585
  }
1533
1586
  try {
1534
- const payload = await this.#http.request({
1587
+ const payload = await this.#send({
1535
1588
  method: "POST",
1536
1589
  path: AUTH_PATHS.refresh,
1590
+ skipQueue: true,
1591
+ skipAuth: true,
1592
+ skipAuthRefresh: true
1537
1593
  // Тела нет намеренно: сервер читает refresh-токен только из cookie — см.
1538
1594
  // #seedRefreshCookie. По той же причине не нужен и устаревший Bearer.
1539
- skipAuth: true,
1540
- // Без этого 401 на самом обновлении вызвал бы новое обновление — и так по кругу.
1541
- skipAuthRefresh: true,
1542
- // Обновление почти всегда запускается изнутри запроса, который занимает место
1543
- // в очереди и ждёт его результата. Встать в ту же очередь — значит зависнуть.
1544
- skipQueue: true
1545
1595
  });
1546
1596
  const accessToken = readAccessToken(payload);
1547
1597
  if (!accessToken) return this.#reloginOrNull();
@@ -1608,20 +1658,18 @@ var AuthManager = class {
1608
1658
  }
1609
1659
  if (credentials.turnstileToken) return credentials.turnstileToken;
1610
1660
  throw new ItdConfigError(
1611
- "\u0412\u0445\u043E\u0434 \u043F\u043E email \u0438 \u043F\u0430\u0440\u043E\u043B\u044E \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0442\u043E\u043A\u0435\u043D \u043A\u0430\u043F\u0447\u0438 Cloudflare Turnstile: \u0431\u0435\u0437 \u043D\u0435\u0433\u043E \u0441\u0435\u0440\u0432\u0435\u0440 \u043E\u0442\u0432\u0435\u0447\u0430\u0435\u0442 422. \u041F\u0435\u0440\u0435\u0434\u0430\u0439\u0442\u0435 auth.getTurnstileToken (\u0438\u0441\u0442\u043E\u0447\u043D\u0438\u043A \u0441\u0432\u0435\u0436\u0435\u0433\u043E \u0442\u043E\u043A\u0435\u043D\u0430) \u043B\u0438\u0431\u043E \u0440\u0430\u0437\u043E\u0432\u044B\u0439 auth.turnstileToken. \u041A\u043B\u044E\u0447 \u0432\u0438\u0434\u0436\u0435\u0442\u0430 \u2014 TURNSTILE_SITE_KEY. \u0412 Node \u0442\u043E\u043A\u0435\u043D \u0443\u043C\u0435\u0435\u0442 \u0434\u043E\u0431\u044B\u0432\u0430\u0442\u044C \u043E\u0442\u0434\u0435\u043B\u044C\u043D\u044B\u0439 \u043F\u0430\u043A\u0435\u0442: npm i itd-api-turnstile, \u0437\u0430\u0442\u0435\u043C getTurnstileToken: createTurnstileSolver()."
1661
+ "\u0412\u0445\u043E\u0434 \u043F\u043E email \u0438 \u043F\u0430\u0440\u043E\u043B\u044E \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0442\u043E\u043A\u0435\u043D \u043A\u0430\u043F\u0447\u0438 Cloudflare Turnstile: \u0431\u0435\u0437 \u043D\u0435\u0433\u043E \u0441\u0435\u0440\u0432\u0435\u0440 \u043E\u0442\u0432\u0435\u0447\u0430\u0435\u0442 422. \u041F\u0435\u0440\u0435\u0434\u0430\u0439\u0442\u0435 auth.getTurnstileToken (\u0438\u0441\u0442\u043E\u0447\u043D\u0438\u043A \u0441\u0432\u0435\u0436\u0435\u0433\u043E \u0442\u043E\u043A\u0435\u043D\u0430) \u043B\u0438\u0431\u043E \u0440\u0430\u0437\u043E\u0432\u044B\u0439 auth.turnstileToken. \u041A\u043B\u044E\u0447 \u0432\u0438\u0434\u0436\u0435\u0442\u0430 \u2014 TURNSTILE_SITE_KEY. \u0412 Node \u0442\u043E\u043A\u0435\u043D \u0443\u043C\u0435\u0435\u0442 \u0434\u043E\u0431\u044B\u0432\u0430\u0442\u044C \u043E\u0442\u0434\u0435\u043B\u044C\u043D\u044B\u0439 \u043F\u0430\u043A\u0435\u0442: npm i @itd-api/turnstile, \u0437\u0430\u0442\u0435\u043C getTurnstileToken: createTurnstileSolver()."
1612
1662
  );
1613
1663
  }
1614
1664
  async #performSignIn(credentials) {
1615
1665
  const turnstileToken = await this.#resolveTurnstileToken(credentials);
1616
- const payload = await this.#http.request({
1666
+ const payload = await this.#send({
1617
1667
  method: "POST",
1618
1668
  path: AUTH_PATHS.signIn,
1619
1669
  body: { email: credentials.email, password: credentials.password, turnstileToken },
1670
+ skipQueue: true,
1620
1671
  skipAuth: true,
1621
- skipAuthRefresh: true,
1622
- // Отложенный вход происходит при сборке заголовков уже начатого запроса — тот держит
1623
- // место в очереди и ждёт токена. См. `skipQueue` в RawRequestOptions.
1624
- skipQueue: true
1672
+ skipAuthRefresh: true
1625
1673
  });
1626
1674
  const accessToken = readAccessToken(payload);
1627
1675
  if (!accessToken) {
@@ -1655,6 +1703,7 @@ var MemoryTokenStorage = class {
1655
1703
  var LocalStorageTokenStorage = class {
1656
1704
  #key;
1657
1705
  #fallback = new MemoryTokenStorage();
1706
+ /** Доступен ли `localStorage` для дальнейших операций. */
1658
1707
  #available;
1659
1708
  /** @param key ключ в `localStorage`. По умолчанию `itd-api:session`. */
1660
1709
  constructor(key = "itd-api:session") {
@@ -1673,26 +1722,31 @@ var LocalStorageTokenStorage = class {
1673
1722
  }
1674
1723
  }
1675
1724
  set(session) {
1676
- if (!this.#available) {
1677
- this.#fallback.set(session);
1678
- return;
1679
- }
1680
- try {
1681
- globalThis.localStorage.setItem(this.#key, JSON.stringify(session));
1682
- } catch {
1683
- this.#fallback.set(session);
1725
+ if (this.#available) {
1726
+ try {
1727
+ globalThis.localStorage.setItem(this.#key, JSON.stringify(session));
1728
+ return;
1729
+ } catch {
1730
+ this.#degrade();
1731
+ }
1684
1732
  }
1733
+ this.#fallback.set(session);
1685
1734
  }
1686
1735
  clear() {
1687
- if (!this.#available) {
1688
- this.#fallback.clear();
1689
- return;
1690
- }
1691
- try {
1692
- globalThis.localStorage.removeItem(this.#key);
1693
- } catch {
1694
- this.#fallback.clear();
1736
+ if (this.#available) {
1737
+ try {
1738
+ globalThis.localStorage.removeItem(this.#key);
1739
+ return;
1740
+ } catch {
1741
+ this.#degrade();
1742
+ }
1695
1743
  }
1744
+ this.#fallback.clear();
1745
+ }
1746
+ /** Переводит хранилище в память без переноса прежнего значения. */
1747
+ #degrade() {
1748
+ this.#available = false;
1749
+ this.#fallback.clear();
1696
1750
  }
1697
1751
  };
1698
1752
  function createTokenStorage(handlers) {
@@ -1747,10 +1801,17 @@ function normalizeBaseUrl(baseUrl) {
1747
1801
  return parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, ""));
1748
1802
  }
1749
1803
 
1804
+ // src/core/version.ts
1805
+ var LIBRARY_VERSION = "0.0.9";
1806
+
1750
1807
  // src/core/config.ts
1751
1808
  var DEFAULT_BASE_URL = "https://xn--d1ah4a.com";
1809
+ var DEFAULT_STATUS_BASE_URL = "https://xn--80a7abcbg.xn--d1ah4a.com";
1810
+ var STATUS_SERVICE = "status";
1811
+ var BUILT_IN_SERVICES = Object.freeze([
1812
+ Object.freeze({ name: STATUS_SERVICE, baseUrl: DEFAULT_STATUS_BASE_URL, auth: false })
1813
+ ]);
1752
1814
  var DEFAULT_TIMEOUT = 3e4;
1753
- var LIBRARY_VERSION = "0.0.7";
1754
1815
  var DEFAULT_USER_AGENT = `Mozilla/5.0 (compatible; itd-api/${LIBRARY_VERSION}; +https://github.com/KiowDev/itd-api)`;
1755
1816
  var DEFAULT_RATE_LIMIT_DELAYS = Object.freeze([1e3, 5e3, 3e4, 6e4, 9e4]);
1756
1817
  function requirePositive(value, name) {
@@ -1862,6 +1923,12 @@ function validateAuth(auth) {
1862
1923
  "auth \u043D\u0435 \u0440\u0430\u0441\u043F\u043E\u0437\u043D\u0430\u043D. \u041E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F \u0441\u0442\u0440\u043E\u043A\u0430 \u0441 accessToken \u043B\u0438\u0431\u043E \u043E\u0431\u044A\u0435\u043A\u0442 { accessToken }, { email, password } \u0438\u043B\u0438 { getToken }"
1863
1924
  );
1864
1925
  }
1926
+ function resolveServices(services) {
1927
+ if (!services) return [];
1928
+ return Object.entries(services).map(
1929
+ ([name, value]) => typeof value === "string" ? { name, baseUrl: value } : { ...value, name }
1930
+ );
1931
+ }
1865
1932
  function resolveConfig(options = {}) {
1866
1933
  const mode = options.mode ?? RuntimeMode.Auto;
1867
1934
  if (!Object.values(RuntimeMode).includes(mode)) {
@@ -1875,6 +1942,7 @@ function resolveConfig(options = {}) {
1875
1942
  }
1876
1943
  return {
1877
1944
  baseUrl: normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL),
1945
+ services: resolveServices(options.services),
1878
1946
  auth: validateAuth(options.auth),
1879
1947
  storage: options.storage ?? new MemoryTokenStorage(),
1880
1948
  autoRefresh: options.autoRefresh ?? true,
@@ -1895,176 +1963,629 @@ function resolveConfig(options = {}) {
1895
1963
  };
1896
1964
  }
1897
1965
 
1898
- // src/core/redact.ts
1899
- var SECRET_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie", "x-api-key"]);
1900
- var SECRET_FIELDS = /* @__PURE__ */ new Set([
1901
- "password",
1902
- "oldpassword",
1903
- "newpassword",
1904
- "accesstoken",
1905
- "refreshtoken",
1906
- "currentpassword",
1907
- "flowtoken",
1908
- "token",
1909
- "turnstiletoken",
1910
- "otp"
1911
- ]);
1912
- function maskSecret(value) {
1913
- if (value.length <= 8) return "\u2026";
1914
- return `${value.slice(0, 4)}\u2026(${value.length})\u2026${value.slice(-3)}`;
1915
- }
1916
- function redactHeaders(headers) {
1917
- const result = {};
1918
- headers.forEach((value, name) => {
1919
- if (SECRET_HEADERS.has(name.toLowerCase())) {
1920
- const spaceAt = value.indexOf(" ");
1921
- result[name] = spaceAt > 0 ? `${value.slice(0, spaceAt)} ${maskSecret(value.slice(spaceAt + 1))}` : maskSecret(value);
1922
- return;
1923
- }
1924
- result[name] = value;
1925
- });
1926
- return result;
1927
- }
1928
- function redactBody(body) {
1929
- if (body === null || body === void 0) return body;
1930
- if (typeof FormData !== "undefined" && body instanceof FormData) return "[FormData]";
1931
- if (isBlob(body)) return "[Blob]";
1932
- if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return "[binary]";
1933
- if (Array.isArray(body)) return body.map(redactBody);
1934
- if (typeof body === "object") {
1935
- const result = {};
1936
- for (const [key, value] of Object.entries(body)) {
1937
- result[key] = SECRET_FIELDS.has(key.toLowerCase()) ? "[\u0441\u043A\u0440\u044B\u0442\u043E]" : redactBody(value);
1938
- }
1939
- return result;
1966
+ // src/core/http.ts
1967
+ var HttpClient = class {
1968
+ #handler;
1969
+ #plugins;
1970
+ #baseUrl;
1971
+ constructor(deps) {
1972
+ this.#handler = deps.handler;
1973
+ this.#plugins = deps.plugins;
1974
+ this.#baseUrl = deps.baseUrl;
1940
1975
  }
1941
- return body;
1976
+ /** Базовый URL, к которому обращается клиент. */
1977
+ get baseUrl() {
1978
+ return this.#baseUrl;
1979
+ }
1980
+ /**
1981
+ * Имена опций запроса, заявленные плагинами.
1982
+ *
1983
+ * Читается ресурсами: они переносят в транспорт только известные поля, а чужие,
1984
+ * если их никто не заявил, отсеивают.
1985
+ */
1986
+ get pluginOptionKeys() {
1987
+ return this.#plugins.optionKeys;
1988
+ }
1989
+ /**
1990
+ * Выполняет запрос к API через собранный конвейер.
1991
+ *
1992
+ * @typeParam T ожидаемая форма ответа после снятия обёртки `{ data: … }`
1993
+ * @throws {ItdApiError} если сервер ответил статусом ≥ 400
1994
+ * @throws {ItdTimeoutError} если истёк таймаут
1995
+ * @throws {ItdAbortError} если запрос отменён через `signal`
1996
+ * @throws {ItdNetworkError} если запрос не дошёл до сервера
1997
+ */
1998
+ request(options) {
1999
+ return this.#handler(options);
2000
+ }
2001
+ };
2002
+
2003
+ // src/core/pipeline.ts
2004
+ function composePipeline(middlewares, final) {
2005
+ return middlewares.reduceRight(
2006
+ (next, middleware) => (request) => middleware(request, next),
2007
+ final
2008
+ );
2009
+ }
2010
+ function withLayerHeaders(request, headers) {
2011
+ return { ...request, layerHeaders: { ...request.layerHeaders, ...headers } };
1942
2012
  }
1943
2013
 
1944
- // src/core/unwrap.ts
1945
- function unwrapData(body) {
1946
- if (typeof body !== "object" || body === null || Array.isArray(body)) return body;
1947
- const keys = Object.keys(body);
1948
- if (keys.length !== 1 || keys[0] !== "data") return body;
1949
- return body.data;
2014
+ // src/core/retry.ts
2015
+ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
2016
+ function isRetryable(error, method, retryWrites) {
2017
+ if (error instanceof ItdAbortError) return false;
2018
+ const safeToRepeat = retryWrites || IDEMPOTENT_METHODS.has(method);
2019
+ if (error instanceof ItdApiError) {
2020
+ if (error.status === 429) return true;
2021
+ if (error.status >= 500) return safeToRepeat;
2022
+ return false;
2023
+ }
2024
+ if (error instanceof ItdNetworkError || error instanceof ItdTimeoutError) return safeToRepeat;
2025
+ return false;
1950
2026
  }
1951
- function isRecord(value) {
1952
- return typeof value === "object" && value !== null && !Array.isArray(value);
2027
+ function backoffDelay(attempt, options, random) {
2028
+ const exponential = options.baseDelay * 2 ** (attempt - 1);
2029
+ const capped = Math.min(exponential, options.maxDelay);
2030
+ const spread = capped * options.jitter * (random() * 2 - 1);
2031
+ return Math.max(0, Math.round(capped + spread));
1953
2032
  }
1954
- function asString(value) {
1955
- return typeof value === "string" && value.length > 0 ? value : void 0;
2033
+ function createRetryScheduler(options, random = Math.random) {
2034
+ return (error, attempt, method) => {
2035
+ if (attempt >= options.attempts) return void 0;
2036
+ if (options.shouldRetry) {
2037
+ return options.shouldRetry(error, attempt) ? backoffDelay(attempt, options, random) : void 0;
2038
+ }
2039
+ if (!isRetryable(error, method, options.retryWrites)) return void 0;
2040
+ if (error instanceof ItdApiError && error.retryAfter !== void 0) {
2041
+ return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
2042
+ }
2043
+ return backoffDelay(attempt, options, random);
2044
+ };
1956
2045
  }
1957
- function pickArray(source, field) {
1958
- if (typeof source !== "object" || source === null) return [];
1959
- const value = source[field];
1960
- return Array.isArray(value) ? value : [];
2046
+
2047
+ // src/core/middleware.ts
2048
+ function sleep(ms) {
2049
+ return new Promise((resolve) => setTimeout(resolve, ms));
1961
2050
  }
1962
- function pickObject(source, field) {
1963
- if (typeof source !== "object" || source === null) return void 0;
1964
- const value = source[field];
1965
- if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1966
- return value;
2051
+ function createQueueMiddleware(schedule) {
2052
+ return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
1967
2053
  }
1968
- function pickBoolean(source, field, fallback = false) {
1969
- if (typeof source !== "object" || source === null) return fallback;
1970
- const value = source[field];
1971
- return typeof value === "boolean" ? value : fallback;
2054
+ function createPluginsMiddleware(plugins) {
2055
+ return (request, next) => {
2056
+ if (plugins.size === 0) return next(request);
2057
+ return plugins.run(request, next);
2058
+ };
1972
2059
  }
1973
- function pickNumber(source, field, fallback) {
1974
- if (typeof source !== "object" || source === null) return fallback;
1975
- const value = source[field];
1976
- return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2060
+ function createServicesMiddleware(registry) {
2061
+ return async (request, next) => {
2062
+ if (request.service === void 0) return next(request);
2063
+ const service = registry.require(request.service);
2064
+ let prepared = request.baseUrl === void 0 ? { ...request, baseUrl: service.baseUrl } : request;
2065
+ if (service.headers) prepared = withLayerHeaders(prepared, service.headers);
2066
+ if (service.auth === false && prepared.skipAuth === void 0) {
2067
+ prepared = { ...prepared, skipAuth: true };
2068
+ }
2069
+ return next(prepared);
2070
+ };
1977
2071
  }
1978
- function pickString(source, field) {
1979
- if (typeof source !== "object" || source === null) return void 0;
1980
- const value = source[field];
1981
- return typeof value === "string" && value.length > 0 ? value : void 0;
2072
+ async function applyAuth(request, deps) {
2073
+ if (request.skipAuth) return request;
2074
+ const headers = await deps.getAuthHeaders();
2075
+ return Object.keys(headers).length > 0 ? withLayerHeaders(request, headers) : request;
1982
2076
  }
1983
-
1984
- // src/core/error-factory.ts
1985
- function collectFieldErrors(source) {
1986
- const result = {};
1987
- const errors = source.errors;
1988
- if (isRecord(errors)) {
1989
- for (const [field, value] of Object.entries(errors)) {
1990
- if (Array.isArray(value)) {
1991
- const messages = value.filter((item) => typeof item === "string");
1992
- if (messages.length > 0) result[field] = messages;
1993
- } else if (typeof value === "string") {
1994
- result[field] = [value];
2077
+ function createAuthMiddleware(deps) {
2078
+ return async (request, next) => {
2079
+ const authorized = await applyAuth(request, deps);
2080
+ try {
2081
+ return await next(authorized);
2082
+ } catch (error) {
2083
+ if (request.skipAuthRefresh || !deps.autoRefresh || !isItdApiError(error) || error.status !== 401) {
2084
+ throw error;
1995
2085
  }
2086
+ const refreshed = await deps.onUnauthorized();
2087
+ if (!refreshed) throw error;
2088
+ const retried = await applyAuth({ ...request, skipAuthRefresh: true }, deps);
2089
+ return next(retried);
1996
2090
  }
1997
- }
1998
- const violations = source.violations;
1999
- if (Array.isArray(violations)) {
2000
- for (const violation of violations) {
2001
- if (!isRecord(violation)) continue;
2002
- const field = asString(violation.field) ?? asString(violation.property);
2003
- const message = asString(violation.message);
2004
- if (!field || !message) continue;
2005
- const existing = result[field];
2006
- if (existing) existing.push(message);
2007
- else result[field] = [message];
2008
- }
2009
- }
2010
- return result;
2011
- }
2012
- function parseErrorBody(body, status, statusText = "") {
2013
- const fallbackMessage = statusText ? `HTTP ${status} ${statusText}` : `HTTP ${status}`;
2014
- if (typeof body === "string") {
2015
- return {
2016
- code: "UNKNOWN_ERROR",
2017
- message: asString(body.trim()) ?? fallbackMessage,
2018
- detail: void 0,
2019
- title: void 0,
2020
- fieldErrors: {},
2021
- userId: void 0
2022
- };
2023
- }
2024
- if (!isRecord(body)) {
2025
- return {
2026
- code: "UNKNOWN_ERROR",
2027
- message: fallbackMessage,
2028
- detail: void 0,
2029
- title: void 0,
2030
- fieldErrors: {},
2031
- userId: void 0
2032
- };
2033
- }
2034
- if (body.type === "validation") {
2035
- const target = asString(body.on);
2036
- return {
2037
- code: "VALIDATION_ERROR",
2038
- message: target ? `\u041F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 \u043D\u0435 \u043F\u0440\u043E\u0439\u0434\u0435\u043D\u0430: \u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u0432 \xAB${target}\xBB` : "\u041F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 \u0432\u0445\u043E\u0434\u043D\u044B\u0445 \u0434\u0430\u043D\u043D\u044B\u0445 \u043D\u0435 \u043F\u0440\u043E\u0439\u0434\u0435\u043D\u0430",
2039
- detail: void 0,
2040
- title: void 0,
2041
- fieldErrors: {},
2042
- userId: void 0
2043
- };
2044
- }
2045
- const inner = isRecord(body.error) ? body.error : body;
2046
- const message = asString(inner.message) ?? asString(inner.detail) ?? asString(inner.title) ?? // `{ "error": "Invalid token" }` — так отвечает сервер на недействительный токен.
2047
- asString(body.error) ?? fallbackMessage;
2048
- return {
2049
- code: asString(inner.code) ?? asString(body.code) ?? "UNKNOWN_ERROR",
2050
- message,
2051
- detail: asString(inner.detail),
2052
- title: asString(inner.title),
2053
- fieldErrors: { ...collectFieldErrors(body), ...collectFieldErrors(inner) },
2054
- userId: asString(inner.userId) ?? asString(body.userId)
2055
2091
  };
2056
2092
  }
2057
- function parseRetryAfter(header, now = Date.now()) {
2058
- if (!header) return void 0;
2059
- const seconds = Number(header);
2060
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
2061
- const date = Date.parse(header);
2062
- if (Number.isFinite(date)) return Math.max(0, date - now);
2063
- return void 0;
2093
+ function resolveBackoff(retry, global) {
2094
+ if (retry === void 0) return global;
2095
+ if (retry === false) return void 0;
2096
+ const resolved = resolveRetry(retry);
2097
+ return resolved ? createRetryScheduler(resolved) : void 0;
2064
2098
  }
2065
- function readIntHeader(headers, name) {
2066
- const raw = headers?.get(name);
2067
- if (raw === null || raw === void 0) return void 0;
2099
+ function createRetryMiddleware(deps) {
2100
+ const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
2101
+ const nextDelay = (error, attempt, request, method, backoff) => {
2102
+ if (isItdRateLimitError(error)) {
2103
+ const wait = error.retryAfter ?? deps.rateLimitDelays[attempt - 1];
2104
+ if (wait === void 0) return void 0;
2105
+ deps.pauseQueue?.(wait, request);
2106
+ deps.logger?.debug(`\u043B\u0438\u043C\u0438\u0442 \u0447\u0430\u0441\u0442\u043E\u0442\u044B, \u043F\u043E\u043F\u044B\u0442\u043A\u0430 ${attempt + 1} \u0447\u0435\u0440\u0435\u0437 ${wait} \u043C\u0441`);
2107
+ return wait;
2108
+ }
2109
+ return backoff?.(error, attempt, method);
2110
+ };
2111
+ return async (request, next) => {
2112
+ const method = request.method.toUpperCase();
2113
+ const backoff = resolveBackoff(request.retry, globalScheduler);
2114
+ for (let attempt = 1; ; attempt++) {
2115
+ try {
2116
+ return await next({ ...request, attempt });
2117
+ } catch (error) {
2118
+ const delay = nextDelay(error, attempt, request, method, backoff);
2119
+ if (delay === void 0) throw error;
2120
+ await deps.hooks.onRetry?.({
2121
+ method,
2122
+ path: request.path,
2123
+ url: deps.buildUrl(request),
2124
+ // Умолчания транспорта добавляются после слоя повторов и сюда не входят.
2125
+ headers: new Headers({ ...request.layerHeaders, ...request.headers }),
2126
+ attempt,
2127
+ error,
2128
+ delay
2129
+ });
2130
+ deps.logger?.debug(
2131
+ `\u043F\u043E\u0432\u0442\u043E\u0440 ${method} ${request.path}, \u043F\u043E\u043F\u044B\u0442\u043A\u0430 ${attempt + 1} \u0447\u0435\u0440\u0435\u0437 ${delay} \u043C\u0441`
2132
+ );
2133
+ await sleep(delay);
2134
+ }
2135
+ }
2136
+ };
2137
+ }
2138
+
2139
+ // src/core/plugins.ts
2140
+ var NO_KEYS = /* @__PURE__ */ new Set();
2141
+ var RESERVED_OPTION_KEYS = /* @__PURE__ */ new Set([
2142
+ "signal",
2143
+ "timeout",
2144
+ "headers",
2145
+ "retry",
2146
+ "method",
2147
+ "path",
2148
+ "service",
2149
+ "baseUrl",
2150
+ "query",
2151
+ "body",
2152
+ "skipAuth",
2153
+ "skipAuthRefresh",
2154
+ "skipQueue",
2155
+ "raw"
2156
+ ]);
2157
+ var PluginRegistry = class {
2158
+ #transformers = [];
2159
+ #optionKeys = /* @__PURE__ */ new Set();
2160
+ #names = /* @__PURE__ */ new Set();
2161
+ /** Сколько обёрток подключено. Ноль означает, что запрос идёт прежним путём. */
2162
+ get size() {
2163
+ return this.#transformers.length;
2164
+ }
2165
+ /** Имена опций запроса, заявленные плагинами. */
2166
+ get optionKeys() {
2167
+ return this.#optionKeys.size === 0 ? NO_KEYS : this.#optionKeys;
2168
+ }
2169
+ /**
2170
+ * Подключает плагин.
2171
+ *
2172
+ * @throws {ItdConfigError} если плагин задан неверно, уже подключён или заявил занятое
2173
+ * имя опции
2174
+ */
2175
+ add(plugin, context) {
2176
+ if (typeof plugin?.install !== "function") {
2177
+ throw new ItdConfigError("\u041F\u043B\u0430\u0433\u0438\u043D \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u043E\u0431\u044A\u0435\u043A\u0442\u043E\u043C \u0441 \u043C\u0435\u0442\u043E\u0434\u043E\u043C install()");
2178
+ }
2179
+ const name = plugin.name;
2180
+ if (typeof name !== "string" || name.trim() === "") {
2181
+ throw new ItdConfigError("\u0423 \u043F\u043B\u0430\u0433\u0438\u043D\u0430 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043D\u0435\u043F\u0443\u0441\u0442\u043E\u0435 \u0438\u043C\u044F");
2182
+ }
2183
+ if (this.#names.has(name)) {
2184
+ throw new ItdConfigError(`\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u0443\u0436\u0435 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0451\u043D`);
2185
+ }
2186
+ const keys = plugin.optionKeys ?? [];
2187
+ for (const key of keys) {
2188
+ if (typeof key !== "string" || key.trim() === "") {
2189
+ throw new ItdConfigError(`\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u0437\u0430\u044F\u0432\u0438\u043B \u043F\u0443\u0441\u0442\u043E\u0435 \u0438\u043C\u044F \u043E\u043F\u0446\u0438\u0438`);
2190
+ }
2191
+ if (RESERVED_OPTION_KEYS.has(key)) {
2192
+ throw new ItdConfigError(
2193
+ `\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u0437\u0430\u044F\u0432\u0438\u043B \u043E\u043F\u0446\u0438\u044E \xAB${key}\xBB: \u044D\u0442\u043E \u043F\u043E\u043B\u0435 \u0437\u0430\u043F\u0440\u043E\u0441\u0430, \u0438\u043C\u044F \u0437\u0430\u043D\u044F\u0442\u043E. \u0417\u0430\u043D\u044F\u0442\u044B\u0435 \u0438\u043C\u0435\u043D\u0430: ${[...RESERVED_OPTION_KEYS].join(", ")}`
2194
+ );
2195
+ }
2196
+ }
2197
+ const before = this.#transformers.length;
2198
+ try {
2199
+ plugin.install({
2200
+ ...context,
2201
+ use: (transformer) => {
2202
+ if (typeof transformer !== "function") {
2203
+ throw new ItdConfigError(`\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u043F\u0435\u0440\u0435\u0434\u0430\u043B \u0432 use() \u043D\u0435 \u0444\u0443\u043D\u043A\u0446\u0438\u044E`);
2204
+ }
2205
+ this.#transformers.push(transformer);
2206
+ }
2207
+ });
2208
+ } catch (error) {
2209
+ this.#transformers.length = before;
2210
+ throw error;
2211
+ }
2212
+ this.#names.add(name);
2213
+ for (const key of keys) this.#optionKeys.add(key);
2214
+ }
2215
+ /**
2216
+ * Прогоняет запрос через цепочку обёрток.
2217
+ *
2218
+ * Цепочка собирается на каждый запрос заново: плагин можно подключить в любой момент,
2219
+ * а обёрток единицы — экономить тут не на чем.
2220
+ *
2221
+ * @param execute настоящий запрос, вызывается самой внутренней обёрткой
2222
+ */
2223
+ run(request, execute) {
2224
+ const chain = this.#transformers.reduceRight(
2225
+ (next, transformer) => (current) => transformer(current, next),
2226
+ execute
2227
+ );
2228
+ return chain(request);
2229
+ }
2230
+ };
2231
+
2232
+ // src/core/rate-limit.ts
2233
+ var RequestQueue = class {
2234
+ #concurrency;
2235
+ /** Минимальный промежуток между стартами, мс. `0` — без ограничения частоты. */
2236
+ #minGap;
2237
+ #waiting = [];
2238
+ #active = 0;
2239
+ /** Момент, раньше которого следующий запрос стартовать не должен. */
2240
+ #nextSlot = 0;
2241
+ #timer;
2242
+ constructor(options) {
2243
+ this.#concurrency = options.concurrency;
2244
+ this.#minGap = options.rps ? 1e3 / options.rps : 0;
2245
+ }
2246
+ /** Сколько задач выполняется прямо сейчас. */
2247
+ get active() {
2248
+ return this.#active;
2249
+ }
2250
+ /** Сколько задач ждёт очереди. */
2251
+ get pending() {
2252
+ return this.#waiting.length;
2253
+ }
2254
+ /**
2255
+ * Ставит задачу в очередь.
2256
+ *
2257
+ * @returns результат задачи; ошибка задачи пробрасывается без изменений
2258
+ */
2259
+ schedule(task) {
2260
+ return new Promise((resolve, reject) => {
2261
+ const run = () => {
2262
+ this.#active += 1;
2263
+ task().then(resolve, reject).finally(() => {
2264
+ this.#active -= 1;
2265
+ this.#drain();
2266
+ });
2267
+ };
2268
+ this.#waiting.push({ run, cancel: reject });
2269
+ this.#drain();
2270
+ });
2271
+ }
2272
+ /**
2273
+ * Останавливает очередь: снимает отложенную паузу и отклоняет ещё не начатые задачи
2274
+ * ошибкой `ItdAbortError`. Уже выполняющиеся задачи доводятся до конца.
2275
+ */
2276
+ stop() {
2277
+ if (this.#timer !== void 0) {
2278
+ clearTimeout(this.#timer);
2279
+ this.#timer = void 0;
2280
+ }
2281
+ this.#nextSlot = 0;
2282
+ const pending = this.#waiting.splice(0, this.#waiting.length);
2283
+ for (const task of pending) {
2284
+ task.cancel(new ItdAbortError("\u041A\u043B\u0438\u0435\u043D\u0442 \u0437\u0430\u043A\u0440\u044B\u0442, \u0437\u0430\u043F\u0440\u043E\u0441 \u043E\u0442\u043C\u0435\u043D\u0451\u043D"));
2285
+ }
2286
+ }
2287
+ /**
2288
+ * Придерживает всю очередь на заданное время.
2289
+ *
2290
+ * Вызывается при получении `429` с заголовком `Retry-After`: тормозить нужно все запросы,
2291
+ * а не только тот, который наткнулся на лимит, — иначе остальные продолжат добивать API.
2292
+ */
2293
+ pause(ms) {
2294
+ if (ms <= 0) return;
2295
+ this.#nextSlot = Math.max(this.#nextSlot, Date.now() + ms);
2296
+ }
2297
+ /** Запускает столько ожидающих задач, сколько позволяют ограничения. */
2298
+ #drain() {
2299
+ if (this.#waiting.length === 0) return;
2300
+ if (this.#active >= this.#concurrency) return;
2301
+ if (this.#timer !== void 0) return;
2302
+ const now = Date.now();
2303
+ if (this.#nextSlot > now) {
2304
+ this.#timer = setTimeout(() => {
2305
+ this.#timer = void 0;
2306
+ this.#drain();
2307
+ }, this.#nextSlot - now);
2308
+ return;
2309
+ }
2310
+ const next = this.#waiting.shift();
2311
+ if (!next) return;
2312
+ if (this.#minGap > 0) this.#nextSlot = now + this.#minGap;
2313
+ next.run();
2314
+ this.#drain();
2315
+ }
2316
+ };
2317
+ var RequestQueuePool = class {
2318
+ #options;
2319
+ #main;
2320
+ /** Очереди сервисов заводятся при первом запросе — обычно не нужна ни одна. */
2321
+ #byService = /* @__PURE__ */ new Map();
2322
+ constructor(options) {
2323
+ this.#options = options;
2324
+ this.#main = new RequestQueue(options);
2325
+ }
2326
+ /** Очередь хоста. */
2327
+ for(service) {
2328
+ if (service === void 0) return this.#main;
2329
+ let queue = this.#byService.get(service);
2330
+ if (!queue) {
2331
+ queue = new RequestQueue(this.#options);
2332
+ this.#byService.set(service, queue);
2333
+ }
2334
+ return queue;
2335
+ }
2336
+ /** Останавливает все очереди. */
2337
+ stop() {
2338
+ this.#main.stop();
2339
+ for (const queue of this.#byService.values()) queue.stop();
2340
+ }
2341
+ };
2342
+
2343
+ // src/core/services.ts
2344
+ function hostOf(url) {
2345
+ try {
2346
+ return new URL(url).hostname.toLowerCase();
2347
+ } catch {
2348
+ return "";
2349
+ }
2350
+ }
2351
+ function isSameSite(primaryHost, host) {
2352
+ if (!primaryHost || !host) return false;
2353
+ return host === primaryHost || host.endsWith(`.${primaryHost}`);
2354
+ }
2355
+ var ServiceRegistry = class {
2356
+ #services = /* @__PURE__ */ new Map();
2357
+ /** Хост основного API. */
2358
+ #primaryHost;
2359
+ /** @param primaryBaseUrl базовый URL клиента */
2360
+ constructor(primaryBaseUrl) {
2361
+ this.#primaryHost = primaryBaseUrl ? hostOf(primaryBaseUrl) : "";
2362
+ }
2363
+ /**
2364
+ * Регистрирует сервис. Имя очищается от краевых пробелов, базовый URL приводится
2365
+ * к каноничному виду, а незаданный `auth` выводится из хоста.
2366
+ *
2367
+ * @throws {ItdConfigError} если имя пустое, имя занято или `baseUrl` не абсолютный URL
2368
+ */
2369
+ define(definition) {
2370
+ const raw = definition?.name;
2371
+ if (typeof raw !== "string" || raw.trim() === "") {
2372
+ throw new ItdConfigError("\u0423 \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043D\u0435\u043F\u0443\u0441\u0442\u043E\u0435 \u0438\u043C\u044F");
2373
+ }
2374
+ const name = raw.trim();
2375
+ if (this.#services.has(name)) {
2376
+ throw new ItdConfigError(`\u0421\u0435\u0440\u0432\u0438\u0441 \xAB${name}\xBB \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043E\u0432\u0430\u043D \u0438 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u0437\u0430\u043C\u0435\u043D\u0451\u043D`);
2377
+ }
2378
+ const baseUrl = normalizeBaseUrl(definition.baseUrl);
2379
+ this.#services.set(name, {
2380
+ ...definition,
2381
+ name,
2382
+ baseUrl,
2383
+ auth: definition.auth ?? isSameSite(this.#primaryHost, hostOf(baseUrl))
2384
+ });
2385
+ }
2386
+ /** Определение сервиса либо `undefined`, если такого нет. */
2387
+ get(name) {
2388
+ return this.#services.get(name);
2389
+ }
2390
+ /** Зарегистрирован ли сервис с таким именем. */
2391
+ has(name) {
2392
+ return this.#services.has(name);
2393
+ }
2394
+ /**
2395
+ * Определение сервиса.
2396
+ *
2397
+ * @throws {ItdConfigError} если сервис не зарегистрирован
2398
+ */
2399
+ require(name) {
2400
+ const service = this.#services.get(name);
2401
+ if (!service) {
2402
+ const known = [...this.#services.keys()];
2403
+ throw new ItdConfigError(
2404
+ `\u0421\u0435\u0440\u0432\u0438\u0441 \xAB${name}\xBB \u043D\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043E\u0432\u0430\u043D. ` + (known.length > 0 ? `\u0418\u0437\u0432\u0435\u0441\u0442\u043D\u044B: ${known.join(", ")}` : "\u0417\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0439\u0442\u0435 \u0435\u0433\u043E \u0447\u0435\u0440\u0435\u0437 itd.defineService() \u0438\u043B\u0438 \u043E\u043F\u0446\u0438\u044E services")
2405
+ );
2406
+ }
2407
+ return service;
2408
+ }
2409
+ /**
2410
+ * Базовый URL сервиса.
2411
+ *
2412
+ * @throws {ItdConfigError} если сервис не зарегистрирован
2413
+ */
2414
+ resolveBaseUrl(name) {
2415
+ return this.require(name).baseUrl;
2416
+ }
2417
+ };
2418
+
2419
+ // src/core/redact.ts
2420
+ var SECRET_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie", "x-api-key"]);
2421
+ var SECRET_FIELDS = /* @__PURE__ */ new Set([
2422
+ "password",
2423
+ "oldpassword",
2424
+ "newpassword",
2425
+ "accesstoken",
2426
+ "refreshtoken",
2427
+ "currentpassword",
2428
+ "flowtoken",
2429
+ "token",
2430
+ "turnstiletoken",
2431
+ "otp"
2432
+ ]);
2433
+ function maskSecret(value) {
2434
+ if (value.length <= 8) return "\u2026";
2435
+ return `${value.slice(0, 4)}\u2026(${value.length})\u2026${value.slice(-3)}`;
2436
+ }
2437
+ function redactHeaders(headers) {
2438
+ const result = {};
2439
+ headers.forEach((value, name) => {
2440
+ if (SECRET_HEADERS.has(name.toLowerCase())) {
2441
+ const spaceAt = value.indexOf(" ");
2442
+ result[name] = spaceAt > 0 ? `${value.slice(0, spaceAt)} ${maskSecret(value.slice(spaceAt + 1))}` : maskSecret(value);
2443
+ return;
2444
+ }
2445
+ result[name] = value;
2446
+ });
2447
+ return result;
2448
+ }
2449
+ function redactBody(body) {
2450
+ if (body === null || body === void 0) return body;
2451
+ if (typeof FormData !== "undefined" && body instanceof FormData) return "[FormData]";
2452
+ if (isBlob(body)) return "[Blob]";
2453
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return "[binary]";
2454
+ if (Array.isArray(body)) return body.map(redactBody);
2455
+ if (typeof body === "object") {
2456
+ const result = {};
2457
+ for (const [key, value] of Object.entries(body)) {
2458
+ result[key] = SECRET_FIELDS.has(key.toLowerCase()) ? "[\u0441\u043A\u0440\u044B\u0442\u043E]" : redactBody(value);
2459
+ }
2460
+ return result;
2461
+ }
2462
+ return body;
2463
+ }
2464
+
2465
+ // src/core/unwrap.ts
2466
+ function unwrapData(body) {
2467
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return body;
2468
+ const keys = Object.keys(body);
2469
+ if (keys.length !== 1 || keys[0] !== "data") return body;
2470
+ return body.data;
2471
+ }
2472
+ function isRecord(value) {
2473
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2474
+ }
2475
+ function asString(value) {
2476
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2477
+ }
2478
+ function pickArray(source, field) {
2479
+ if (typeof source !== "object" || source === null) return [];
2480
+ const value = source[field];
2481
+ return Array.isArray(value) ? value : [];
2482
+ }
2483
+ function pickObject(source, field) {
2484
+ if (typeof source !== "object" || source === null) return void 0;
2485
+ const value = source[field];
2486
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
2487
+ return value;
2488
+ }
2489
+ function pickBoolean(source, field, fallback = false) {
2490
+ if (typeof source !== "object" || source === null) return fallback;
2491
+ const value = source[field];
2492
+ return typeof value === "boolean" ? value : fallback;
2493
+ }
2494
+ function pickNumber(source, field, fallback) {
2495
+ if (typeof source !== "object" || source === null) return fallback;
2496
+ const value = source[field];
2497
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2498
+ }
2499
+ function pickString(source, field) {
2500
+ if (typeof source !== "object" || source === null) return void 0;
2501
+ const value = source[field];
2502
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2503
+ }
2504
+
2505
+ // src/core/error-factory.ts
2506
+ function collectFieldErrors(source) {
2507
+ const result = {};
2508
+ const errors = source.errors;
2509
+ if (isRecord(errors)) {
2510
+ for (const [field, value] of Object.entries(errors)) {
2511
+ if (Array.isArray(value)) {
2512
+ const messages = value.filter((item) => typeof item === "string");
2513
+ if (messages.length > 0) result[field] = messages;
2514
+ } else if (typeof value === "string") {
2515
+ result[field] = [value];
2516
+ }
2517
+ }
2518
+ }
2519
+ const violations = source.violations;
2520
+ if (Array.isArray(violations)) {
2521
+ for (const violation of violations) {
2522
+ if (!isRecord(violation)) continue;
2523
+ const field = asString(violation.field) ?? asString(violation.property);
2524
+ const message = asString(violation.message);
2525
+ if (!field || !message) continue;
2526
+ const existing = result[field];
2527
+ if (existing) existing.push(message);
2528
+ else result[field] = [message];
2529
+ }
2530
+ }
2531
+ return result;
2532
+ }
2533
+ function parseErrorBody(body, status, statusText = "") {
2534
+ const fallbackMessage = statusText ? `HTTP ${status} ${statusText}` : `HTTP ${status}`;
2535
+ if (typeof body === "string") {
2536
+ return {
2537
+ code: "UNKNOWN_ERROR",
2538
+ message: asString(body.trim()) ?? fallbackMessage,
2539
+ detail: void 0,
2540
+ title: void 0,
2541
+ fieldErrors: {},
2542
+ userId: void 0
2543
+ };
2544
+ }
2545
+ if (!isRecord(body)) {
2546
+ return {
2547
+ code: "UNKNOWN_ERROR",
2548
+ message: fallbackMessage,
2549
+ detail: void 0,
2550
+ title: void 0,
2551
+ fieldErrors: {},
2552
+ userId: void 0
2553
+ };
2554
+ }
2555
+ if (body.type === "validation") {
2556
+ const target = asString(body.on);
2557
+ return {
2558
+ code: "VALIDATION_ERROR",
2559
+ message: target ? `\u041F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 \u043D\u0435 \u043F\u0440\u043E\u0439\u0434\u0435\u043D\u0430: \u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u0432 \xAB${target}\xBB` : "\u041F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 \u0432\u0445\u043E\u0434\u043D\u044B\u0445 \u0434\u0430\u043D\u043D\u044B\u0445 \u043D\u0435 \u043F\u0440\u043E\u0439\u0434\u0435\u043D\u0430",
2560
+ detail: void 0,
2561
+ title: void 0,
2562
+ fieldErrors: {},
2563
+ userId: void 0
2564
+ };
2565
+ }
2566
+ const inner = isRecord(body.error) ? body.error : body;
2567
+ const message = asString(inner.message) ?? asString(inner.detail) ?? asString(inner.title) ?? // `{ "error": "Invalid token" }` — так отвечает сервер на недействительный токен.
2568
+ asString(body.error) ?? fallbackMessage;
2569
+ return {
2570
+ code: asString(inner.code) ?? asString(body.code) ?? "UNKNOWN_ERROR",
2571
+ message,
2572
+ detail: asString(inner.detail),
2573
+ title: asString(inner.title),
2574
+ fieldErrors: { ...collectFieldErrors(body), ...collectFieldErrors(inner) },
2575
+ userId: asString(inner.userId) ?? asString(body.userId)
2576
+ };
2577
+ }
2578
+ function parseRetryAfter(header, now = Date.now()) {
2579
+ if (!header) return void 0;
2580
+ const seconds = Number(header);
2581
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
2582
+ const date = Date.parse(header);
2583
+ if (Number.isFinite(date)) return Math.max(0, date - now);
2584
+ return void 0;
2585
+ }
2586
+ function readIntHeader(headers, name) {
2587
+ const raw = headers?.get(name);
2588
+ if (raw === null || raw === void 0) return void 0;
2068
2589
  const value = Number.parseInt(raw, 10);
2069
2590
  return Number.isFinite(value) ? value : void 0;
2070
2591
  }
@@ -2141,11 +2662,7 @@ function createApiError(context) {
2141
2662
  return new Ctor(init);
2142
2663
  }
2143
2664
 
2144
- // src/core/http.ts
2145
- function sleep(ms) {
2146
- return new Promise((resolve) => setTimeout(resolve, ms));
2147
- }
2148
- var EMPTY_KEYS = /* @__PURE__ */ new Set();
2665
+ // src/core/transport.ts
2149
2666
  function setHeader(headers, name, value) {
2150
2667
  try {
2151
2668
  headers.set(name, value);
@@ -2175,6 +2692,29 @@ async function readBody(response) {
2175
2692
  const text = await response.text();
2176
2693
  return text === "" ? void 0 : text;
2177
2694
  }
2695
+ function createAbortError() {
2696
+ const error = new Error("\u041E\u043F\u0435\u0440\u0430\u0446\u0438\u044F \u043F\u0440\u0435\u0440\u0432\u0430\u043D\u0430");
2697
+ error.name = "AbortError";
2698
+ return error;
2699
+ }
2700
+ function abortable(promise, signal) {
2701
+ if (signal.aborted) {
2702
+ return Promise.race([
2703
+ promise,
2704
+ new Promise((_resolve, reject) => {
2705
+ setTimeout(() => reject(createAbortError()), 0);
2706
+ })
2707
+ ]);
2708
+ }
2709
+ let onAbort;
2710
+ const interrupted = new Promise((_resolve, reject) => {
2711
+ onAbort = () => reject(createAbortError());
2712
+ signal.addEventListener("abort", onAbort, { once: true });
2713
+ });
2714
+ return Promise.race([promise, interrupted]).finally(() => {
2715
+ if (onAbort) signal.removeEventListener("abort", onAbort);
2716
+ });
2717
+ }
2178
2718
  function createAbortBundle(userSignal, timeout) {
2179
2719
  const controller = new AbortController();
2180
2720
  let timedOut = false;
@@ -2183,418 +2723,201 @@ function createAbortBundle(userSignal, timeout) {
2183
2723
  if (userSignal.aborted) controller.abort(userSignal.reason);
2184
2724
  else userSignal.addEventListener("abort", onUserAbort, { once: true });
2185
2725
  }
2186
- const timer = timeout > 0 ? setTimeout(() => {
2187
- timedOut = true;
2188
- controller.abort();
2189
- }, timeout) : void 0;
2190
- return {
2191
- signal: controller.signal,
2192
- timedOut: () => timedOut,
2193
- cleanup: () => {
2194
- if (timer !== void 0) clearTimeout(timer);
2195
- userSignal?.removeEventListener("abort", onUserAbort);
2196
- }
2197
- };
2198
- }
2199
- var HttpClient = class {
2200
- #config;
2201
- #collaborators;
2202
- #plugins;
2203
- constructor(config, collaborators = {}) {
2204
- this.#config = config;
2205
- this.#collaborators = collaborators;
2206
- }
2207
- /** Базовый URL, к которому обращается клиент. */
2208
- get baseUrl() {
2209
- return this.#config.baseUrl;
2210
- }
2211
- /**
2212
- * Имена опций запроса, заявленные плагинами.
2213
- *
2214
- * Читается ресурсами: они переносят в транспорт только известные поля, а чужие,
2215
- * если их никто не заявил, отсеивают.
2216
- */
2217
- get pluginOptionKeys() {
2218
- return this.#plugins?.optionKeys ?? EMPTY_KEYS;
2219
- }
2220
- /** Подключает список плагинов. Реестр общий с клиентом и пополняется через `itd.use()`. */
2221
- usePlugins(plugins) {
2222
- this.#plugins = plugins;
2223
- }
2224
- /**
2225
- * Подключает недостающие части конвейера.
2226
- *
2227
- * Нужно из-за кольцевой зависимости: слой авторизации сам выполняет запросы, поэтому
2228
- * не может быть передан в конструктор до создания транспорта.
2229
- */
2230
- setCollaborators(collaborators) {
2231
- this.#collaborators = { ...this.#collaborators, ...collaborators };
2232
- }
2233
- /**
2234
- * Выполняет запрос к API.
2235
- *
2236
- * @typeParam T ожидаемая форма ответа после снятия обёртки `{ data: … }`
2237
- * @throws {ItdApiError} если сервер ответил статусом ≥ 400
2238
- * @throws {ItdTimeoutError} если истёк таймаут
2239
- * @throws {ItdAbortError} если запрос отменён через `signal`
2240
- * @throws {ItdNetworkError} если запрос не дошёл до сервера
2241
- */
2242
- async request(options) {
2243
- const task = () => this.#withPlugins(options);
2244
- if (!this.#collaborators.schedule || options.skipQueue) return task();
2245
- return this.#collaborators.schedule(task);
2246
- }
2247
- /**
2248
- * Прогоняет запрос через обёртки плагинов.
2249
- *
2250
- * Цепочка стоит **снаружи повторов и внутри очереди**: плагин должен увидеть запрос
2251
- * и ответ по одному разу, независимо от того, сколько попыток понадобилось, — иначе,
2252
- * например, текст поста зашифруется повторно на второй попытке.
2253
- */
2254
- #withPlugins(options) {
2255
- const plugins = this.#plugins;
2256
- if (!plugins || plugins.size === 0) return this.#withRetries(options);
2257
- return plugins.run(options, (request) => this.#withRetries(request));
2258
- }
2259
- async #withRetries(options) {
2260
- const method = options.method.toUpperCase();
2261
- for (let attempt = 1; ; attempt++) {
2262
- try {
2263
- return await this.#attempt(options, attempt);
2264
- } catch (error) {
2265
- const delay = this.#collaborators.nextRetryDelay?.(error, attempt, method);
2266
- if (delay === void 0) throw error;
2267
- await this.#config.hooks.onRetry?.({
2268
- method,
2269
- path: options.path,
2270
- url: this.#buildUrl(options),
2271
- headers: new Headers(),
2272
- attempt,
2273
- error,
2274
- delay
2275
- });
2276
- this.#config.logger?.debug(
2277
- `\u043F\u043E\u0432\u0442\u043E\u0440 ${method} ${options.path}, \u043F\u043E\u043F\u044B\u0442\u043A\u0430 ${attempt + 1} \u0447\u0435\u0440\u0435\u0437 ${delay} \u043C\u0441`
2278
- );
2279
- await sleep(delay);
2280
- }
2281
- }
2282
- }
2283
- #buildUrl(options) {
2284
- return joinUrl(this.#config.baseUrl, options.path) + buildQuery(options.query);
2285
- }
2286
- async #buildHeaders(options, url) {
2287
- const headers = new Headers();
2288
- headers.set("Accept", "application/json");
2289
- headers.set("X-Requested-With", "XMLHttpRequest");
2290
- if (this.#config.userAgent) setHeader(headers, "User-Agent", this.#config.userAgent);
2291
- if (this.#collaborators.getDeviceId) {
2292
- setHeader(headers, "X-Device-Id", await this.#collaborators.getDeviceId());
2293
- }
2294
- for (const [name, value] of Object.entries(this.#config.headers))
2295
- setHeader(headers, name, value);
2296
- if (!options.skipAuth && this.#collaborators.getAuthHeaders) {
2297
- const auth = await this.#collaborators.getAuthHeaders();
2298
- for (const [name, value] of Object.entries(auth)) setHeader(headers, name, value);
2299
- }
2300
- if (this.#config.useCookieJar && this.#collaborators.getCookieHeader) {
2301
- const cookie = this.#collaborators.getCookieHeader(url);
2302
- if (cookie) setHeader(headers, "Cookie", cookie);
2303
- }
2304
- for (const [name, value] of Object.entries(options.headers ?? {})) {
2305
- setHeader(headers, name, value);
2306
- }
2307
- return headers;
2308
- }
2309
- async #attempt(options, attempt) {
2310
- const method = options.method.toUpperCase();
2311
- const url = this.#buildUrl(options);
2312
- const headers = await this.#buildHeaders(options, url);
2313
- let body;
2314
- if (options.body !== void 0 && options.body !== null) {
2315
- if (isRawBody(options.body)) {
2316
- body = options.body;
2317
- } else {
2318
- body = JSON.stringify(options.body);
2319
- if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
2320
- }
2321
- }
2322
- const context = { method, path: options.path, url, headers, attempt };
2323
- await this.#config.hooks.onRequest?.(context);
2324
- const timeout = options.timeout ?? this.#config.timeout;
2325
- const abort = createAbortBundle(options.signal, timeout);
2326
- const startedAt = Date.now();
2327
- this.#config.logger?.debug(`\u2192 ${method} ${options.path}`, {
2328
- headers: redactHeaders(headers),
2329
- body: redactBody(options.body)
2330
- });
2331
- let response;
2332
- try {
2333
- response = await this.#config.fetch(url, {
2334
- method,
2335
- headers,
2336
- signal: abort.signal,
2337
- ...body !== void 0 ? { body } : {},
2338
- ...this.#config.sendCredentials ? { credentials: "include" } : {}
2339
- });
2340
- } catch (error) {
2341
- const duration2 = Date.now() - startedAt;
2342
- const failure = this.#toTransportError(error, abort, options, method, timeout);
2343
- await this.#config.hooks.onError?.({ ...context, duration: duration2, error: failure });
2344
- this.#config.logger?.warn(`\xD7 ${method} ${options.path} (${duration2} \u043C\u0441): ${failure.message}`);
2345
- throw failure;
2346
- } finally {
2347
- abort.cleanup();
2348
- }
2349
- const duration = Date.now() - startedAt;
2350
- if (this.#collaborators.onRateLimit) {
2351
- const { limit, remaining } = readRateLimit(response.headers);
2352
- this.#collaborators.onRateLimit(limit, remaining);
2353
- }
2354
- if (this.#config.useCookieJar) this.#collaborators.saveCookies?.(url, response);
2355
- const payload = await readBody(response);
2356
- if (!response.ok) {
2357
- if (response.status === 401 && !options.skipAuthRefresh && this.#config.autoRefresh && this.#collaborators.onUnauthorized) {
2358
- const refreshed = await this.#collaborators.onUnauthorized();
2359
- if (refreshed) {
2360
- this.#config.logger?.debug(`\u0442\u043E\u043A\u0435\u043D \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D, \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E ${method} ${options.path}`);
2361
- return this.#attempt({ ...options, skipAuthRefresh: true }, attempt);
2362
- }
2363
- }
2364
- const error = createApiError({
2365
- method,
2366
- path: options.path,
2367
- status: response.status,
2368
- statusText: response.statusText,
2369
- headers: response.headers,
2370
- response,
2371
- body: payload
2372
- });
2373
- await this.#config.hooks.onError?.({ ...context, duration, error });
2374
- this.#config.logger?.warn(
2375
- `\u2190 ${response.status} ${method} ${options.path} (${duration} \u043C\u0441): ${error.message}`
2376
- );
2377
- throw error;
2378
- }
2379
- await this.#config.hooks.onResponse?.({
2380
- ...context,
2381
- status: response.status,
2382
- duration,
2383
- response
2384
- });
2385
- this.#config.logger?.debug(`\u2190 ${response.status} ${method} ${options.path} (${duration} \u043C\u0441)`);
2386
- return options.raw ? payload : unwrapData(payload);
2387
- }
2388
- /** Превращает исключение `fetch` в понятную ошибку библиотеки. */
2389
- #toTransportError(error, abort, options, method, timeout) {
2390
- const aborted = error instanceof Error && error.name === "AbortError";
2391
- if (aborted && abort.timedOut()) {
2392
- return new ItdTimeoutError({ timeout, method, path: options.path });
2393
- }
2394
- if (aborted) {
2395
- return new ItdAbortError(`\u0417\u0430\u043F\u0440\u043E\u0441 ${method} ${options.path} \u043E\u0442\u043C\u0435\u043D\u0451\u043D`);
2396
- }
2397
- return new ItdNetworkError(
2398
- `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u044C ${method} ${options.path}: ${error instanceof Error ? error.message : String(error)}`,
2399
- { method, path: options.path, cause: error }
2400
- );
2401
- }
2402
- };
2403
-
2404
- // src/core/plugins.ts
2405
- var NO_KEYS = /* @__PURE__ */ new Set();
2406
- var RESERVED_OPTION_KEYS = /* @__PURE__ */ new Set([
2407
- "signal",
2408
- "timeout",
2409
- "headers",
2410
- "retry",
2411
- "method",
2412
- "path",
2413
- "query",
2414
- "body",
2415
- "skipAuth",
2416
- "skipAuthRefresh",
2417
- "skipQueue",
2418
- "raw"
2419
- ]);
2420
- var PluginRegistry = class {
2421
- #transformers = [];
2422
- #optionKeys = /* @__PURE__ */ new Set();
2423
- #names = /* @__PURE__ */ new Set();
2424
- /** Сколько обёрток подключено. Ноль означает, что запрос идёт прежним путём. */
2425
- get size() {
2426
- return this.#transformers.length;
2726
+ const timer = timeout > 0 ? setTimeout(() => {
2727
+ timedOut = true;
2728
+ controller.abort();
2729
+ }, timeout) : void 0;
2730
+ return {
2731
+ signal: controller.signal,
2732
+ timedOut: () => timedOut,
2733
+ cleanup: () => {
2734
+ if (timer !== void 0) clearTimeout(timer);
2735
+ userSignal?.removeEventListener("abort", onUserAbort);
2736
+ }
2737
+ };
2738
+ }
2739
+ var Transport = class {
2740
+ #config;
2741
+ #deps;
2742
+ constructor(config, deps) {
2743
+ this.#config = config;
2744
+ this.#deps = deps;
2427
2745
  }
2428
- /** Имена опций запроса, заявленные плагинами. */
2429
- get optionKeys() {
2430
- return this.#optionKeys.size === 0 ? NO_KEYS : this.#optionKeys;
2746
+ /** Базовый URL, к которому обращается транспорт. */
2747
+ get baseUrl() {
2748
+ return this.#config.baseUrl;
2431
2749
  }
2432
2750
  /**
2433
- * Подключает плагин.
2751
+ * Выполняет один сетевой запрос.
2434
2752
  *
2435
- * @throws {ItdConfigError} если плагин задан неверно, уже подключён или заявил занятое
2436
- * имя опции
2753
+ * @throws {ItdApiError} если сервер ответил статусом 400
2754
+ * @throws {ItdTimeoutError} если истёк таймаут
2755
+ * @throws {ItdAbortError} если запрос отменён через `signal`
2756
+ * @throws {ItdNetworkError} если запрос не дошёл до сервера
2437
2757
  */
2438
- add(plugin, context) {
2439
- if (typeof plugin?.install !== "function") {
2440
- throw new ItdConfigError("\u041F\u043B\u0430\u0433\u0438\u043D \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u043E\u0431\u044A\u0435\u043A\u0442\u043E\u043C \u0441 \u043C\u0435\u0442\u043E\u0434\u043E\u043C install()");
2441
- }
2442
- const name = plugin.name;
2443
- if (typeof name !== "string" || name.trim() === "") {
2444
- throw new ItdConfigError("\u0423 \u043F\u043B\u0430\u0433\u0438\u043D\u0430 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043D\u0435\u043F\u0443\u0441\u0442\u043E\u0435 \u0438\u043C\u044F");
2445
- }
2446
- if (this.#names.has(name)) {
2447
- throw new ItdConfigError(`\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u0443\u0436\u0435 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0451\u043D`);
2758
+ send = async (request) => {
2759
+ const method = request.method.toUpperCase();
2760
+ const url = this.buildUrl(request);
2761
+ const headers = await this.#buildHeaders(request, url);
2762
+ const attempt = request.attempt ?? 1;
2763
+ let body;
2764
+ if (request.body !== void 0 && request.body !== null) {
2765
+ if (isRawBody(request.body)) {
2766
+ body = request.body;
2767
+ } else {
2768
+ body = JSON.stringify(request.body);
2769
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
2770
+ }
2448
2771
  }
2449
- const keys = plugin.optionKeys ?? [];
2450
- for (const key of keys) {
2451
- if (typeof key !== "string" || key.trim() === "") {
2452
- throw new ItdConfigError(`\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u0437\u0430\u044F\u0432\u0438\u043B \u043F\u0443\u0441\u0442\u043E\u0435 \u0438\u043C\u044F \u043E\u043F\u0446\u0438\u0438`);
2772
+ const context = { method, path: request.path, url, headers, attempt };
2773
+ await this.#config.hooks.onRequest?.(context);
2774
+ const timeout = request.timeout ?? this.#config.timeout;
2775
+ const abort = createAbortBundle(request.signal, timeout);
2776
+ const startedAt = Date.now();
2777
+ this.#config.logger?.debug(`\u2192 ${method} ${request.path}`, {
2778
+ headers: redactHeaders(headers),
2779
+ body: redactBody(request.body)
2780
+ });
2781
+ try {
2782
+ let response;
2783
+ try {
2784
+ response = await this.#config.fetch(url, {
2785
+ method,
2786
+ headers,
2787
+ signal: abort.signal,
2788
+ ...body !== void 0 ? { body } : {},
2789
+ ...this.#config.sendCredentials ? { credentials: "include" } : {}
2790
+ });
2791
+ } catch (error) {
2792
+ const duration2 = Date.now() - startedAt;
2793
+ const failure = this.#toTransportError(error, abort, request, method, timeout);
2794
+ await this.#config.hooks.onError?.({ ...context, duration: duration2, error: failure });
2795
+ this.#config.logger?.warn(
2796
+ `\xD7 ${method} ${request.path} (${duration2} \u043C\u0441): ${failure.message}`
2797
+ );
2798
+ throw failure;
2453
2799
  }
2454
- if (RESERVED_OPTION_KEYS.has(key)) {
2455
- throw new ItdConfigError(
2456
- `\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u0437\u0430\u044F\u0432\u0438\u043B \u043E\u043F\u0446\u0438\u044E \xAB${key}\xBB: \u044D\u0442\u043E \u043F\u043E\u043B\u0435 \u0437\u0430\u043F\u0440\u043E\u0441\u0430, \u0438\u043C\u044F \u0437\u0430\u043D\u044F\u0442\u043E. \u0417\u0430\u043D\u044F\u0442\u044B\u0435 \u0438\u043C\u0435\u043D\u0430: ${[...RESERVED_OPTION_KEYS].join(", ")}`
2800
+ if (this.#deps.onRateLimit) {
2801
+ const { limit, remaining } = readRateLimit(response.headers);
2802
+ this.#deps.onRateLimit(limit, remaining, request);
2803
+ }
2804
+ if (this.#config.useCookieJar) this.#deps.cookies?.setFromResponse(url, response);
2805
+ if (response.ok) {
2806
+ await this.#config.hooks.onResponse?.({
2807
+ ...context,
2808
+ status: response.status,
2809
+ duration: Date.now() - startedAt,
2810
+ response
2811
+ });
2812
+ }
2813
+ const payload = await this.#readBodyOrFail(
2814
+ response,
2815
+ context,
2816
+ request,
2817
+ method,
2818
+ abort,
2819
+ timeout
2820
+ );
2821
+ const duration = Date.now() - startedAt;
2822
+ if (!response.ok) {
2823
+ const error = createApiError({
2824
+ method,
2825
+ path: request.path,
2826
+ status: response.status,
2827
+ statusText: response.statusText,
2828
+ headers: response.headers,
2829
+ response,
2830
+ body: payload
2831
+ });
2832
+ await this.#config.hooks.onError?.({ ...context, duration, error });
2833
+ this.#config.logger?.warn(
2834
+ `\u2190 ${response.status} ${method} ${request.path} (${duration} \u043C\u0441): ${error.message}`
2457
2835
  );
2836
+ throw error;
2458
2837
  }
2838
+ this.#config.logger?.debug(`\u2190 ${response.status} ${method} ${request.path} (${duration} \u043C\u0441)`);
2839
+ return request.raw ? payload : unwrapData(payload);
2840
+ } finally {
2841
+ abort.cleanup();
2459
2842
  }
2460
- const before = this.#transformers.length;
2843
+ };
2844
+ /** Читает тело и преобразует ошибку чтения в транспортную ошибку библиотеки. */
2845
+ async #readBodyOrFail(response, context, request, method, abort, timeout) {
2846
+ const startedAt = Date.now();
2461
2847
  try {
2462
- plugin.install({
2848
+ return await abortable(readBody(response), abort.signal);
2849
+ } catch (error) {
2850
+ await response.body?.cancel().catch(() => {
2851
+ });
2852
+ const failure = this.#toTransportError(error, abort, request, method, timeout);
2853
+ await this.#config.hooks.onError?.({
2463
2854
  ...context,
2464
- use: (transformer) => {
2465
- if (typeof transformer !== "function") {
2466
- throw new ItdConfigError(`\u041F\u043B\u0430\u0433\u0438\u043D \xAB${name}\xBB \u043F\u0435\u0440\u0435\u0434\u0430\u043B \u0432 use() \u043D\u0435 \u0444\u0443\u043D\u043A\u0446\u0438\u044E`);
2467
- }
2468
- this.#transformers.push(transformer);
2469
- }
2855
+ duration: Date.now() - startedAt,
2856
+ error: failure
2470
2857
  });
2471
- } catch (error) {
2472
- this.#transformers.length = before;
2473
- throw error;
2858
+ this.#config.logger?.warn(
2859
+ `\xD7 ${method} ${request.path}: \u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C \u0442\u0435\u043B\u043E \u043E\u0442\u0432\u0435\u0442\u0430 \u2014 ${failure.message}`
2860
+ );
2861
+ throw failure;
2474
2862
  }
2475
- this.#names.add(name);
2476
- for (const key of keys) this.#optionKeys.add(key);
2477
2863
  }
2478
2864
  /**
2479
- * Прогоняет запрос через цепочку обёрток.
2865
+ * Итоговый URL со строкой запроса. Нужен и слою повторов — для хука `onRetry`.
2480
2866
  *
2481
- * Цепочка собирается на каждый запрос заново: плагин можно подключить в любой момент,
2482
- * а обёрток единицы — экономить тут не на чем.
2483
- *
2484
- * @param execute настоящий запрос, вызывается самой внутренней обёрткой
2867
+ * Хост берётся из самого запроса, если он там задан: у сервисов платформы свои домены.
2485
2868
  */
2486
- run(request, execute) {
2487
- const chain = this.#transformers.reduceRight(
2488
- (next, transformer) => (current) => transformer(current, next),
2489
- execute
2490
- );
2491
- return chain(request);
2492
- }
2493
- };
2494
-
2495
- // src/core/rate-limit.ts
2496
- var RequestQueue = class {
2497
- #concurrency;
2498
- /** Минимальный промежуток между стартами, мс. `0` — без ограничения частоты. */
2499
- #minGap;
2500
- #waiting = [];
2501
- #active = 0;
2502
- /** Момент, раньше которого следующий запрос стартовать не должен. */
2503
- #nextSlot = 0;
2504
- #timer;
2505
- constructor(options) {
2506
- this.#concurrency = options.concurrency;
2507
- this.#minGap = options.rps ? 1e3 / options.rps : 0;
2508
- }
2509
- /** Сколько задач выполняется прямо сейчас. */
2510
- get active() {
2511
- return this.#active;
2512
- }
2513
- /** Сколько задач ждёт очереди. */
2514
- get pending() {
2515
- return this.#waiting.length;
2869
+ buildUrl(request) {
2870
+ const base = request.baseUrl ?? this.#config.baseUrl;
2871
+ return joinUrl(base, request.path) + buildQuery(request.query);
2516
2872
  }
2517
2873
  /**
2518
- * Ставит задачу в очередь.
2519
- *
2520
- * @returns результат задачи; ошибка задачи пробрасывается без изменений
2874
+ * Собирает общие заголовки клиента: `User-Agent`, идентификатор устройства,
2875
+ * заголовки конфигурации и cookie для указанного адреса.
2521
2876
  */
2522
- schedule(task) {
2523
- return new Promise((resolve, reject) => {
2524
- const run = () => {
2525
- this.#active += 1;
2526
- task().then(resolve, reject).finally(() => {
2527
- this.#active -= 1;
2528
- this.#drain();
2529
- });
2530
- };
2531
- this.#waiting.push({ run });
2532
- this.#drain();
2533
- });
2877
+ async platformHeaders(url) {
2878
+ const headers = new Headers();
2879
+ headers.set("X-Requested-With", "XMLHttpRequest");
2880
+ if (this.#config.userAgent) setHeader(headers, "User-Agent", this.#config.userAgent);
2881
+ if (this.#deps.getDeviceId) {
2882
+ setHeader(headers, "X-Device-Id", await this.#deps.getDeviceId());
2883
+ }
2884
+ for (const [name, value] of Object.entries(this.#config.headers))
2885
+ setHeader(headers, name, value);
2886
+ if (this.#config.useCookieJar && this.#deps.cookies) {
2887
+ const cookie = this.#deps.cookies.getHeader(url);
2888
+ if (cookie) setHeader(headers, "Cookie", cookie);
2889
+ }
2890
+ return headers;
2534
2891
  }
2535
2892
  /**
2536
- * Придерживает всю очередь на заданное время.
2537
- *
2538
- * Вызывается при получении `429` с заголовком `Retry-After`: тормозить нужно все запросы,
2539
- * а не только тот, который наткнулся на лимит, — иначе остальные продолжат добивать API.
2893
+ * Дополняет общие заголовки значением `Accept`, заголовками конвейера и вызова.
2894
+ * Заголовки вызова применяются последними.
2540
2895
  */
2541
- pause(ms) {
2542
- if (ms <= 0) return;
2543
- this.#nextSlot = Math.max(this.#nextSlot, Date.now() + ms);
2544
- }
2545
- /** Запускает столько ожидающих задач, сколько позволяют ограничения. */
2546
- #drain() {
2547
- if (this.#waiting.length === 0) return;
2548
- if (this.#active >= this.#concurrency) return;
2549
- if (this.#timer !== void 0) return;
2550
- const now = Date.now();
2551
- if (this.#nextSlot > now) {
2552
- this.#timer = setTimeout(() => {
2553
- this.#timer = void 0;
2554
- this.#drain();
2555
- }, this.#nextSlot - now);
2556
- return;
2896
+ async #buildHeaders(request, url) {
2897
+ const headers = await this.platformHeaders(url);
2898
+ if (!headers.has("Accept")) headers.set("Accept", "application/json");
2899
+ for (const [name, value] of Object.entries(request.layerHeaders ?? {}))
2900
+ setHeader(headers, name, value);
2901
+ for (const [name, value] of Object.entries(request.headers ?? {})) {
2902
+ setHeader(headers, name, value);
2557
2903
  }
2558
- const next = this.#waiting.shift();
2559
- if (!next) return;
2560
- if (this.#minGap > 0) this.#nextSlot = now + this.#minGap;
2561
- next.run();
2562
- this.#drain();
2563
- }
2564
- };
2565
-
2566
- // src/core/retry.ts
2567
- var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
2568
- function isRetryable(error, method, retryWrites) {
2569
- if (error instanceof ItdAbortError) return false;
2570
- const safeToRepeat = retryWrites || IDEMPOTENT_METHODS.has(method);
2571
- if (error instanceof ItdApiError) {
2572
- if (error.status === 429) return true;
2573
- if (error.status >= 500) return safeToRepeat;
2574
- return false;
2904
+ return headers;
2575
2905
  }
2576
- if (error instanceof ItdNetworkError || error instanceof ItdTimeoutError) return safeToRepeat;
2577
- return false;
2578
- }
2579
- function backoffDelay(attempt, options, random) {
2580
- const exponential = options.baseDelay * 2 ** (attempt - 1);
2581
- const capped = Math.min(exponential, options.maxDelay);
2582
- const spread = capped * options.jitter * (random() * 2 - 1);
2583
- return Math.max(0, Math.round(capped + spread));
2584
- }
2585
- function createRetryScheduler(options, random = Math.random) {
2586
- return (error, attempt, method) => {
2587
- if (attempt >= options.attempts) return void 0;
2588
- if (options.shouldRetry) {
2589
- return options.shouldRetry(error, attempt) ? backoffDelay(attempt, options, random) : void 0;
2906
+ /** Превращает исключение `fetch` в понятную ошибку библиотеки. */
2907
+ #toTransportError(error, abort, request, method, timeout) {
2908
+ const aborted = error instanceof Error && error.name === "AbortError";
2909
+ if (aborted && abort.timedOut()) {
2910
+ return new ItdTimeoutError({ timeout, method, path: request.path });
2590
2911
  }
2591
- if (!isRetryable(error, method, options.retryWrites)) return void 0;
2592
- if (error instanceof ItdApiError && error.retryAfter !== void 0) {
2593
- return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
2912
+ if (aborted) {
2913
+ return new ItdAbortError(`\u0417\u0430\u043F\u0440\u043E\u0441 ${method} ${request.path} \u043E\u0442\u043C\u0435\u043D\u0451\u043D`);
2594
2914
  }
2595
- return backoffDelay(attempt, options, random);
2596
- };
2597
- }
2915
+ return new ItdNetworkError(
2916
+ `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u044C ${method} ${request.path}: ${error instanceof Error ? error.message : String(error)}`,
2917
+ { method, path: request.path, cause: error }
2918
+ );
2919
+ }
2920
+ };
2598
2921
 
2599
2922
  // src/notifications/type-map.ts
2600
2923
  var NOTIFICATION_TYPE_ALIASES = Object.freeze({
@@ -2701,14 +3024,15 @@ var PollTransport = class {
2701
3024
  while (!context.signal.aborted) {
2702
3025
  const token = await context.getToken();
2703
3026
  if (!token) throw new UnauthorizedStreamError();
2704
- const headers = new Headers({
2705
- Accept: "application/json",
2706
- Authorization: `Bearer ${token}`
3027
+ const url = `${joinUrl(context.baseUrl, "/api/notifications/")}?limit=${this.#limit}&offset=0`;
3028
+ const headers = await context.baseHeaders(url);
3029
+ headers.set("Accept", "application/json");
3030
+ headers.set("Authorization", `Bearer ${token}`);
3031
+ const response = await context.fetch(url, {
3032
+ method: "GET",
3033
+ headers,
3034
+ signal: context.signal
2707
3035
  });
2708
- const response = await context.fetch(
2709
- `${joinUrl(context.baseUrl, "/api/notifications/")}?limit=${this.#limit}&offset=0`,
2710
- { method: "GET", headers, signal: context.signal }
2711
- );
2712
3036
  if (response.status === 401) throw new UnauthorizedStreamError();
2713
3037
  if (!response.ok) throw new Error(`\u041E\u043F\u0440\u043E\u0441 \u0443\u0432\u0435\u0434\u043E\u043C\u043B\u0435\u043D\u0438\u0439 \u0432\u0435\u0440\u043D\u0443\u043B \u0441\u0442\u0430\u0442\u0443\u0441 ${response.status}`);
2714
3038
  const body = await response.json();
@@ -2964,13 +3288,13 @@ var SseTransport = class {
2964
3288
  async connect(context) {
2965
3289
  const token = await context.getToken();
2966
3290
  if (!token) throw new UnauthorizedStreamError();
2967
- const headers = new Headers({
2968
- Accept: "text/event-stream",
2969
- Authorization: `Bearer ${token}`,
2970
- "Cache-Control": "no-cache"
2971
- });
3291
+ const url = joinUrl(context.baseUrl, STREAM_PATH);
3292
+ const headers = await context.baseHeaders(url);
3293
+ headers.set("Accept", "text/event-stream");
3294
+ headers.set("Authorization", `Bearer ${token}`);
3295
+ headers.set("Cache-Control", "no-cache");
2972
3296
  if (this.#lastEventId) headers.set("Last-Event-ID", this.#lastEventId);
2973
- const response = await context.fetch(joinUrl(context.baseUrl, STREAM_PATH), {
3297
+ const response = await context.fetch(url, {
2974
3298
  method: "GET",
2975
3299
  headers,
2976
3300
  signal: context.signal
@@ -3031,10 +3355,42 @@ var RealtimeTransportKind = Object.freeze({
3031
3355
  /** Периодический опрос REST. */
3032
3356
  Poll: "poll"
3033
3357
  });
3358
+ function validateRealtimeOptions(options) {
3359
+ const positiveInteger = (value, name) => {
3360
+ if (value === void 0) return;
3361
+ if (!Number.isInteger(value) || value < 0) {
3362
+ throw new ItdConfigError(
3363
+ `realtime.${name} \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0446\u0435\u043B\u044B\u043C \u043D\u0435\u043E\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043B\u044C\u043D\u044B\u043C \u0447\u0438\u0441\u043B\u043E\u043C, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E: ${value}`
3364
+ );
3365
+ }
3366
+ };
3367
+ const duration = (value, name, min) => {
3368
+ if (value === void 0) return;
3369
+ if (!Number.isFinite(value) || value < min) {
3370
+ throw new ItdConfigError(
3371
+ `realtime.${name} \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0447\u0438\u0441\u043B\u043E\u043C \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435 ${min}, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E: ${value}`
3372
+ );
3373
+ }
3374
+ };
3375
+ positiveInteger(options.maxAttempts, "maxAttempts");
3376
+ duration(options.pollInterval, "pollInterval", 1);
3377
+ duration(options.idleTimeout, "idleTimeout", 0);
3378
+ if (options.jitter !== void 0 && !(options.jitter >= 0 && options.jitter <= 1)) {
3379
+ throw new ItdConfigError(
3380
+ `realtime.jitter \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0432 \u0434\u0438\u0430\u043F\u0430\u0437\u043E\u043D\u0435 0\u20261, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E: ${options.jitter}`
3381
+ );
3382
+ }
3383
+ if (options.backoff !== void 0) {
3384
+ if (!Array.isArray(options.backoff) || options.backoff.length === 0) {
3385
+ throw new ItdConfigError("realtime.backoff \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u043D\u0435\u043F\u0443\u0441\u0442\u044B\u043C \u0441\u043F\u0438\u0441\u043A\u043E\u043C \u043F\u0430\u0443\u0437");
3386
+ }
3387
+ for (const delay of options.backoff) duration(delay, "backoff", 0);
3388
+ }
3389
+ }
3034
3390
  var ItdRealtime = class {
3035
3391
  #deps;
3036
3392
  #options;
3037
- #emitter = new Emitter();
3393
+ #emitter;
3038
3394
  #transport;
3039
3395
  #maxAttempts;
3040
3396
  #controller;
@@ -3052,10 +3408,16 @@ var ItdRealtime = class {
3052
3408
  #timer;
3053
3409
  #detachEnvironment;
3054
3410
  constructor(deps, options = {}) {
3411
+ validateRealtimeOptions(options);
3055
3412
  this.#deps = deps;
3056
3413
  this.#options = options;
3057
3414
  this.#maxAttempts = options.maxAttempts ?? MAX_RECONNECT_ATTEMPTS;
3058
3415
  this.#transport = this.#createTransport();
3416
+ this.#emitter = new Emitter((error) => {
3417
+ const message = "\u041E\u0448\u0438\u0431\u043A\u0430 \u0432 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0447\u0438\u043A\u0435 \u0441\u043E\u0431\u044B\u0442\u0438\u044F realtime";
3418
+ if (deps.logger) deps.logger.error(message, error);
3419
+ else console.error(`[itd-api] ${message}`, error);
3420
+ });
3059
3421
  }
3060
3422
  /** Текущее состояние соединения. */
3061
3423
  get status() {
@@ -3107,6 +3469,7 @@ var ItdRealtime = class {
3107
3469
  this.#controller = void 0;
3108
3470
  this.#attempt = 0;
3109
3471
  this.#setStatus(RealtimeStatus.Disconnected);
3472
+ this.#deps.onClose?.();
3110
3473
  }
3111
3474
  /** Снимает все подписки. Соединение при этом не закрывается. */
3112
3475
  removeAllListeners() {
@@ -3126,6 +3489,7 @@ var ItdRealtime = class {
3126
3489
  }
3127
3490
  /** Запускает попытку подключения; повторы планирует сам. */
3128
3491
  #run() {
3492
+ if (!this.#wanted) return;
3129
3493
  this.#controller?.abort();
3130
3494
  const controller = new AbortController();
3131
3495
  this.#controller = controller;
@@ -3133,6 +3497,7 @@ var ItdRealtime = class {
3133
3497
  void this.#transport.connect({
3134
3498
  baseUrl: this.#deps.baseUrl,
3135
3499
  fetch: this.#deps.fetch,
3500
+ baseHeaders: this.#deps.baseHeaders,
3136
3501
  getToken: this.#deps.getToken,
3137
3502
  signal: controller.signal,
3138
3503
  onOpen: () => {
@@ -3183,17 +3548,17 @@ var ItdRealtime = class {
3183
3548
  async #refreshAndReconnect(error) {
3184
3549
  this.#setStatus(RealtimeStatus.Error);
3185
3550
  const refreshed = await this.#deps.refresh().catch(() => false);
3551
+ if (!this.#wanted) return;
3186
3552
  if (!refreshed) {
3187
- this.#emitter.emit("error", { error, willReconnect: false });
3188
- this.#emitter.emit("giveup", void 0);
3553
+ this.#giveUp(error);
3189
3554
  return;
3190
3555
  }
3191
3556
  this.#scheduleReconnect(error);
3192
3557
  }
3193
3558
  #scheduleReconnect(error) {
3559
+ if (!this.#wanted) return;
3194
3560
  if (this.#attempt >= this.#maxAttempts) {
3195
- this.#emitter.emit("error", { error, willReconnect: false });
3196
- this.#emitter.emit("giveup", void 0);
3561
+ this.#giveUp(error);
3197
3562
  return;
3198
3563
  }
3199
3564
  const delay = reconnectDelay(this.#attempt, this.#options);
@@ -3205,6 +3570,15 @@ var ItdRealtime = class {
3205
3570
  this.#run();
3206
3571
  }, delay);
3207
3572
  }
3573
+ /** Завершает автоматические попытки переподключения. */
3574
+ #giveUp(error) {
3575
+ this.#wanted = false;
3576
+ this.#attempt = 0;
3577
+ this.#detachEnvironment?.();
3578
+ this.#detachEnvironment = void 0;
3579
+ this.#emitter.emit("error", { error, willReconnect: false });
3580
+ this.#emitter.emit("giveup", void 0);
3581
+ }
3208
3582
  /**
3209
3583
  * Подписывается на события среды.
3210
3584
  *
@@ -3252,6 +3626,9 @@ var PaginationMode = Object.freeze({
3252
3626
  /** Следующая страница запрашивается смещением от начала списка. */
3253
3627
  Offset: "offset"
3254
3628
  });
3629
+ function mapPage(page, map) {
3630
+ return { ...page, items: page.items.map(map) };
3631
+ }
3255
3632
  function readItems(body, fields) {
3256
3633
  if (Array.isArray(body)) return body;
3257
3634
  for (const field of fields) {
@@ -3405,6 +3782,14 @@ var Paginator = class {
3405
3782
  }
3406
3783
  };
3407
3784
 
3785
+ // src/types/options.ts
3786
+ var REQUEST_OPTION_KEYS = [
3787
+ "signal",
3788
+ "timeout",
3789
+ "headers",
3790
+ "retry"
3791
+ ];
3792
+
3408
3793
  // src/resources/base.ts
3409
3794
  var BaseResource = class {
3410
3795
  /** @internal */
@@ -3413,28 +3798,25 @@ var BaseResource = class {
3413
3798
  this.http = http;
3414
3799
  }
3415
3800
  /**
3416
- * Переносит общие поля опций запроса в параметры транспорта.
3801
+ * Переносит опции запроса в описание транспорта.
3417
3802
  *
3418
- * Поля перечислены поимённо, а не скопированы целиком: параметры методов наследуют
3419
- * {@link RequestOptions} и приносят с собой `limit`, `cursor` и прочее, чему в описании
3420
- * запроса делать нечего. Исключение опции, заявленные плагинами: их библиотека
3803
+ * Копируются только поля {@link REQUEST_OPTION_KEYS} и опции, заявленные плагинами:
3804
+ * параметры методов наследуют {@link RequestOptions} и приносят с собой `limit`, `cursor`
3805
+ * и прочее, чему в описании запроса делать нечего. Чужие опции плагинов библиотека
3421
3806
  * не понимает, но обязана донести до обёрток нетронутыми.
3422
3807
  */
3423
3808
  requestOptions(options) {
3424
3809
  if (!options) return {};
3425
- const result = {
3426
- ...options.signal !== void 0 ? { signal: options.signal } : {},
3427
- ...options.timeout !== void 0 ? { timeout: options.timeout } : {},
3428
- ...options.headers !== void 0 ? { headers: options.headers } : {},
3429
- ...options.retry !== void 0 ? { retry: options.retry } : {}
3430
- };
3431
- const pluginKeys = this.http.pluginOptionKeys;
3432
- if (pluginKeys.size === 0) return result;
3433
3810
  const source = options;
3434
- const target = result;
3811
+ const result = {};
3812
+ for (const key of REQUEST_OPTION_KEYS) {
3813
+ const value = source[key];
3814
+ if (value !== void 0) result[key] = value;
3815
+ }
3816
+ const pluginKeys = this.http.pluginOptionKeys;
3435
3817
  for (const key of pluginKeys) {
3436
3818
  const value = source[key];
3437
- if (value !== void 0) target[key] = value;
3819
+ if (value !== void 0) result[key] = value;
3438
3820
  }
3439
3821
  return result;
3440
3822
  }
@@ -3454,6 +3836,42 @@ var BaseResource = class {
3454
3836
  ...options?.start !== void 0 ? { start: options.start } : {}
3455
3837
  });
3456
3838
  }
3839
+ /**
3840
+ * Собирает пару «загрузка страницы + перебор» из одного описания.
3841
+ *
3842
+ * Путь, параметры запроса и разбор ответа задаются один раз; `list` и `iterate`
3843
+ * строятся из них.
3844
+ *
3845
+ * @example
3846
+ * ```ts
3847
+ * #feed = this.paginated<Post, FeedParams>({
3848
+ * path: () => '/api/posts',
3849
+ * query: (p) => ({ tab: p.tab, limit: p.limit }),
3850
+ * start: (p) => (p.cursor ? { cursor: p.cursor } : {}),
3851
+ * read: (body) => readCursorPage<Post>(body, 'posts'),
3852
+ * mode: PaginationMode.Cursor,
3853
+ * });
3854
+ * ```
3855
+ */
3856
+ paginated(spec) {
3857
+ const load = async (params, state) => {
3858
+ const body = await this.http.request({
3859
+ method: "GET",
3860
+ path: spec.path(params),
3861
+ query: withPageState(spec.query(params), state),
3862
+ ...this.requestOptions(params)
3863
+ });
3864
+ return spec.read(body, state);
3865
+ };
3866
+ return {
3867
+ list: (params) => load(params, spec.start(params)),
3868
+ iterate: (params) => this.paginate(spec.mode, (state) => load(params, state), {
3869
+ ...params.maxPages !== void 0 ? { maxPages: params.maxPages } : {},
3870
+ ...params.signal !== void 0 ? { signal: params.signal } : {},
3871
+ start: spec.start(params)
3872
+ })
3873
+ };
3874
+ }
3457
3875
  };
3458
3876
  function withPageState(query, state) {
3459
3877
  return {
@@ -3487,7 +3905,7 @@ var AuthResource = class extends BaseResource {
3487
3905
  async signUp(credentials, options = {}) {
3488
3906
  const body = await this.http.request({
3489
3907
  method: "POST",
3490
- path: "/api/v1/auth/sign-up",
3908
+ path: AUTH_PATHS.signUp,
3491
3909
  body: credentials,
3492
3910
  skipAuth: true,
3493
3911
  skipAuthRefresh: true,
@@ -3512,7 +3930,7 @@ var AuthResource = class extends BaseResource {
3512
3930
  async signIn(credentials, options = {}) {
3513
3931
  const body = await this.http.request({
3514
3932
  method: "POST",
3515
- path: "/api/v1/auth/sign-in",
3933
+ path: AUTH_PATHS.signIn,
3516
3934
  body: credentials,
3517
3935
  skipAuth: true,
3518
3936
  skipAuthRefresh: true,
@@ -3533,7 +3951,7 @@ var AuthResource = class extends BaseResource {
3533
3951
  async verifyOtp(input, options = {}) {
3534
3952
  const body = await this.http.request({
3535
3953
  method: "POST",
3536
- path: "/api/v1/auth/verify-otp",
3954
+ path: AUTH_PATHS.verifyOtp,
3537
3955
  body: input,
3538
3956
  skipAuth: true,
3539
3957
  skipAuthRefresh: true,
@@ -3550,7 +3968,7 @@ var AuthResource = class extends BaseResource {
3550
3968
  resendOtp(input, options = {}) {
3551
3969
  return this.http.request({
3552
3970
  method: "POST",
3553
- path: "/api/v1/auth/resend-otp",
3971
+ path: AUTH_PATHS.resendOtp,
3554
3972
  body: input,
3555
3973
  skipAuth: true,
3556
3974
  skipAuthRefresh: true,
@@ -3627,7 +4045,7 @@ var AuthResource = class extends BaseResource {
3627
4045
  async logout(options = {}) {
3628
4046
  await this.http.request({
3629
4047
  method: "POST",
3630
- path: "/api/v1/auth/logout",
4048
+ path: AUTH_PATHS.logout,
3631
4049
  skipAuthRefresh: true,
3632
4050
  ...this.requestOptions(options)
3633
4051
  });
@@ -3657,7 +4075,7 @@ var AuthResource = class extends BaseResource {
3657
4075
  async forgotPassword(input, options = {}) {
3658
4076
  const body = await this.http.request({
3659
4077
  method: "POST",
3660
- path: "/api/v1/auth/forgot-password",
4078
+ path: AUTH_PATHS.forgotPassword,
3661
4079
  body: input,
3662
4080
  skipAuth: true,
3663
4081
  skipAuthRefresh: true,
@@ -3678,7 +4096,7 @@ var AuthResource = class extends BaseResource {
3678
4096
  resetPassword(input, options = {}) {
3679
4097
  return this.http.request({
3680
4098
  method: "POST",
3681
- path: "/api/v1/auth/reset-password",
4099
+ path: AUTH_PATHS.resetPassword,
3682
4100
  body: input,
3683
4101
  skipAuth: true,
3684
4102
  skipAuthRefresh: true,
@@ -3716,14 +4134,17 @@ var AuthResource = class extends BaseResource {
3716
4134
  * Меняет пароль. Требует действующей сессии.
3717
4135
  *
3718
4136
  * При неверном текущем пароле сервер отвечает `ACCOUNT_CURRENT_PASSWORD_INCORRECT`.
4137
+ *
4138
+ * @example
4139
+ * ```ts
4140
+ * await itd.auth.changePassword({ currentPassword, newPassword });
4141
+ * ```
3719
4142
  */
3720
4143
  changePassword(input, options = {}) {
3721
4144
  return this.http.request({
3722
4145
  method: "POST",
3723
- path: "/api/v1/auth/change-password",
3724
- // Текущий пароль уходит под двумя именами: какое из них ждёт сервер, снаружи
3725
- // не проверить, а лишнее поле он игнорирует.
3726
- body: { ...input, currentPassword: input.oldPassword },
4146
+ path: AUTH_PATHS.changePassword,
4147
+ body: { currentPassword: input.currentPassword, newPassword: input.newPassword },
3727
4148
  ...this.requestOptions(options)
3728
4149
  });
3729
4150
  }
@@ -3739,13 +4160,13 @@ var AuthResource = class extends BaseResource {
3739
4160
  * ```
3740
4161
  */
3741
4162
  oauthUrl(provider) {
3742
- return joinUrl(this.http.baseUrl, `/api/v1/auth/login/${provider}`);
4163
+ return joinUrl(this.http.baseUrl, `${AUTH_PATHS.oauthLogin}/${provider}`);
3743
4164
  }
3744
4165
  /** Загружает список активных сессий. У текущей поле `isCurrent` равно `true`. */
3745
4166
  async sessions(options = {}) {
3746
4167
  const body = await this.http.request({
3747
4168
  method: "GET",
3748
- path: "/api/v1/auth/sessions",
4169
+ path: AUTH_PATHS.sessions,
3749
4170
  ...this.requestOptions(options)
3750
4171
  });
3751
4172
  return pickArray(body, "sessions");
@@ -3754,7 +4175,7 @@ var AuthResource = class extends BaseResource {
3754
4175
  revokeSession(sessionId, options = {}) {
3755
4176
  return this.http.request({
3756
4177
  method: "DELETE",
3757
- path: `/api/v1/auth/sessions/${encodeURIComponent(sessionId)}`,
4178
+ path: `${AUTH_PATHS.sessions}/${encodeURIComponent(sessionId)}`,
3758
4179
  ...this.requestOptions(options)
3759
4180
  });
3760
4181
  }
@@ -3762,7 +4183,7 @@ var AuthResource = class extends BaseResource {
3762
4183
  revokeOtherSessions(options = {}) {
3763
4184
  return this.http.request({
3764
4185
  method: "DELETE",
3765
- path: "/api/v1/auth/sessions",
4186
+ path: AUTH_PATHS.sessions,
3766
4187
  ...this.requestOptions(options)
3767
4188
  });
3768
4189
  }
@@ -3771,6 +4192,14 @@ var AuthResource = class extends BaseResource {
3771
4192
  // src/resources/comments.ts
3772
4193
  var CommentsResource = class extends BaseResource {
3773
4194
  #uploadFiles;
4195
+ /** Ответы на комментарий: `/api/comments/{id}/replies`, постраничная пагинация. */
4196
+ #replies = this.paginated({
4197
+ path: (p) => `/api/comments/${encodePathSegment(p.commentId, "commentId")}/replies`,
4198
+ query: (p) => ({ limit: p.limit }),
4199
+ start: (p) => p.page !== void 0 ? { page: p.page } : {},
4200
+ read: (body) => readPagedPage(body, "replies"),
4201
+ mode: PaginationMode.Page
4202
+ });
3774
4203
  constructor(http, deps) {
3775
4204
  super(http);
3776
4205
  this.#uploadFiles = deps.uploadFiles;
@@ -3780,31 +4209,12 @@ var CommentsResource = class extends BaseResource {
3780
4209
  *
3781
4210
  * Здесь пагинация **постраничная**, в отличие от комментариев к посту, где курсорная.
3782
4211
  */
3783
- async replies(commentId, params = {}) {
3784
- const body = await this.http.request({
3785
- method: "GET",
3786
- path: `/api/comments/${encodePathSegment(commentId, "commentId")}/replies`,
3787
- query: { limit: params.limit, page: params.page },
3788
- ...this.requestOptions(params)
3789
- });
3790
- return readPagedPage(body, "replies");
4212
+ replies(commentId, params = {}) {
4213
+ return this.#replies.list({ ...params, commentId });
3791
4214
  }
3792
4215
  /** Перебирает ответы на комментарий. */
3793
4216
  iterateReplies(commentId, params = {}) {
3794
- const path = `/api/comments/${encodePathSegment(commentId, "commentId")}/replies`;
3795
- return this.paginate(
3796
- PaginationMode.Page,
3797
- async (state) => {
3798
- const body = await this.http.request({
3799
- method: "GET",
3800
- path,
3801
- query: withPageState({ limit: params.limit }, state),
3802
- ...this.requestOptions(params)
3803
- });
3804
- return readPagedPage(body, "replies");
3805
- },
3806
- { ...params, ...params.page !== void 0 ? { start: { page: params.page } } : {} }
3807
- );
4217
+ return this.#replies.iterate({ ...params, commentId });
3808
4218
  }
3809
4219
  /**
3810
4220
  * Отвечает на комментарий.
@@ -3939,19 +4349,14 @@ function assertAllowedMime(mimeType, filename) {
3939
4349
  var DEFAULT_UPLOAD_TIMEOUT = 3e5;
3940
4350
  var FilesResource = class extends BaseResource {
3941
4351
  #readFile;
4352
+ /**
4353
+ * @param deps.readFile чтение файлов с диска. Передаёт точка входа `itd-api/node`;
4354
+ * в основном бандле его нет, чтобы браузерные сборщики не пытались разрешить `node:fs`.
4355
+ */
3942
4356
  constructor(http, deps = {}) {
3943
4357
  super(http);
3944
4358
  this.#readFile = deps.readFile;
3945
4359
  }
3946
- /**
3947
- * Подключает чтение файлов с диска.
3948
- *
3949
- * Вызывается точкой входа `itd-api/node`; в основном бандле работы с файловой
3950
- * системой нет, чтобы браузерные сборщики не пытались разрешить `node:fs`.
3951
- */
3952
- setFileReader(readFile) {
3953
- this.#readFile = readFile;
3954
- }
3955
4360
  /**
3956
4361
  * Загружает файл и возвращает его идентификатор.
3957
4362
  *
@@ -4061,8 +4466,16 @@ var FilesResource = class extends BaseResource {
4061
4466
  }
4062
4467
  };
4063
4468
 
4064
- // src/resources/misc.ts
4469
+ // src/resources/hashtags.ts
4065
4470
  var HashtagsResource = class extends BaseResource {
4471
+ /** Посты по хэштегу: `/api/hashtags/{tag}/posts`, курсорная пагинация. */
4472
+ #posts = this.paginated({
4473
+ path: (p) => `/api/hashtags/${encodePathSegment(p.tag, "tag")}/posts`,
4474
+ query: (p) => ({ limit: p.limit }),
4475
+ start: (p) => p.cursor ? { cursor: p.cursor } : {},
4476
+ read: (body) => readCursorPage(body, "posts"),
4477
+ mode: PaginationMode.Cursor
4478
+ });
4066
4479
  /**
4067
4480
  * Ищет хэштеги.
4068
4481
  *
@@ -4093,210 +4506,48 @@ var HashtagsResource = class extends BaseResource {
4093
4506
  * @param tag название без решётки; кодируется автоматически, поэтому кириллица
4094
4507
  * и пробелы допустимы
4095
4508
  */
4096
- async posts(tag, params = {}) {
4097
- const body = await this.http.request({
4098
- method: "GET",
4099
- path: `/api/hashtags/${encodePathSegment(tag, "tag")}/posts`,
4100
- query: { limit: params.limit, cursor: params.cursor },
4101
- ...this.requestOptions(params)
4102
- });
4103
- return readCursorPage(body, "posts");
4509
+ posts(tag, params = {}) {
4510
+ return this.#posts.list({ ...params, tag });
4104
4511
  }
4105
4512
  /** Перебирает посты по хэштегу. */
4106
4513
  iteratePosts(tag, params = {}) {
4107
- const path = `/api/hashtags/${encodePathSegment(tag, "tag")}/posts`;
4108
- return this.paginate(
4109
- PaginationMode.Cursor,
4110
- async (state) => {
4111
- const body = await this.http.request({
4112
- method: "GET",
4113
- path,
4114
- query: withPageState({ limit: params.limit }, state),
4115
- ...this.requestOptions(params)
4116
- });
4117
- return readCursorPage(body, "posts");
4118
- },
4119
- { ...params, ...params.cursor ? { start: { cursor: params.cursor } } : {} }
4120
- );
4121
- }
4122
- };
4123
- var SearchResource = class extends BaseResource {
4124
- /**
4125
- * Ищет пользователей и хэштеги одним запросом.
4126
- *
4127
- * @example
4128
- * ```ts
4129
- * const { users, hashtags } = await itd.search.all('арт');
4130
- * ```
4131
- */
4132
- async all(query, options = {}) {
4133
- const body = await this.http.request({
4134
- method: "GET",
4135
- path: "/api/search",
4136
- query: { q: query },
4137
- ...this.requestOptions(options)
4138
- });
4139
- return {
4140
- users: pickArray(body, "users"),
4141
- hashtags: pickArray(body, "hashtags")
4142
- };
4143
- }
4144
- };
4145
- var ReportsResource = class extends BaseResource {
4146
- /**
4147
- * Отправляет жалобу.
4148
- *
4149
- * Повторная жалоба на тот же объект отклоняется сервером с сообщением
4150
- * «Вы уже отправляли жалобу на этот контент».
4151
- *
4152
- * @example
4153
- * ```ts
4154
- * await itd.reports.create(report.post(postId).reason('spam'));
4155
- * await itd.reports.create({ targetType: 'user', targetId, reason: 'fraud' });
4156
- * ```
4157
- */
4158
- create(input, options = {}) {
4159
- const data = resolveReport(input);
4160
- return this.http.request({
4161
- method: "POST",
4162
- path: "/api/reports",
4163
- body: data,
4164
- ...this.requestOptions(options)
4165
- });
4166
- }
4167
- };
4168
- var VerificationResource = class extends BaseResource {
4169
- /** Загружает статус заявки. Значение `none` означает, что заявка не подавалась. */
4170
- status(options = {}) {
4171
- return this.http.request({
4172
- method: "GET",
4173
- path: "/api/verification/status",
4174
- ...this.requestOptions(options)
4175
- });
4176
- }
4177
- /** Подаёт заявку на верификацию с видео. */
4178
- submit(videoUrl, options = {}) {
4179
- return this.http.request({
4180
- method: "POST",
4181
- path: "/api/verification/submit",
4182
- body: { videoUrl },
4183
- ...this.requestOptions(options)
4184
- });
4185
- }
4186
- };
4187
- var SubscriptionResource = class extends BaseResource {
4188
- /** Загружает состояние подписки и её цену. */
4189
- status(options = {}) {
4190
- return this.http.request({
4191
- method: "GET",
4192
- // Завершающий слэш обязателен.
4193
- path: "/api/v1/subscription/",
4194
- ...this.requestOptions(options)
4195
- });
4196
- }
4197
- /**
4198
- * Запускает оплату подписки.
4199
- *
4200
- * Форма ответа в документации API не описана, поэтому тип результата не уточняется.
4201
- */
4202
- pay(options = {}) {
4203
- return this.http.request({
4204
- method: "POST",
4205
- path: "/api/v1/subscription/pay",
4206
- ...this.requestOptions(options)
4207
- });
4208
- }
4209
- /** Включает или отключает автопродление. */
4210
- setAutoRenewal(enabled, options = {}) {
4211
- return this.http.request({
4212
- method: "POST",
4213
- path: "/api/v1/subscription/auto-renewal",
4214
- body: { enabled },
4215
- ...this.requestOptions(options)
4216
- });
4217
- }
4218
- /** Запускает привязку карты. */
4219
- bindCard(options = {}) {
4220
- return this.http.request({
4221
- method: "POST",
4222
- path: "/api/v1/subscription/bind-card",
4223
- ...this.requestOptions(options)
4224
- });
4225
- }
4226
- /** Загружает список способов оплаты. Пустой массив, если карт нет. */
4227
- async methods(options = {}) {
4228
- const body = await this.http.request({
4229
- method: "GET",
4230
- path: "/api/v1/subscription/methods",
4231
- ...this.requestOptions(options)
4232
- });
4233
- return Array.isArray(body) ? body : [];
4234
- }
4235
- /** Делает способ оплаты основным. */
4236
- setDefaultMethod(methodId, options = {}) {
4237
- return this.http.request({
4238
- method: "POST",
4239
- path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}/default`,
4240
- ...this.requestOptions(options)
4241
- });
4242
- }
4243
- /** Удаляет способ оплаты. */
4244
- removeMethod(methodId, options = {}) {
4245
- return this.http.request({
4246
- method: "DELETE",
4247
- path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}`,
4248
- ...this.requestOptions(options)
4249
- });
4250
- }
4251
- };
4252
- var PlatformResource = class extends BaseResource {
4253
- /** Загружает журнал изменений. */
4254
- async changelog(options = {}) {
4255
- const body = await this.http.request({
4256
- method: "GET",
4257
- path: "/api/platform/changelog",
4258
- ...this.requestOptions(options)
4259
- });
4260
- return Array.isArray(body) ? body : [];
4261
- }
4262
- /** Загружает анонсы платформы. */
4263
- async announcements(options = {}) {
4264
- const body = await this.http.request({
4265
- method: "GET",
4266
- path: "/api/platform/announcements",
4267
- ...this.requestOptions(options)
4268
- });
4269
- return pickArray(body, "announcements");
4270
- }
4271
- /** Загружает баннер текущего события — виджет «портал». */
4272
- portal(options = {}) {
4273
- return this.http.request({
4274
- method: "GET",
4275
- path: "/api/v1/portal",
4276
- ...this.requestOptions(options)
4277
- });
4514
+ return this.#posts.iterate({ ...params, tag });
4278
4515
  }
4279
4516
  };
4280
4517
 
4281
4518
  // src/resources/notifications.ts
4519
+ var NOTIFICATION_SETTING_KEYS = [
4520
+ "enabled",
4521
+ "sound",
4522
+ "follows",
4523
+ "wallPosts",
4524
+ "likes",
4525
+ "comments",
4526
+ "mentions"
4527
+ ];
4282
4528
  var READ_BATCH_SIZE = 20;
4283
4529
  function readSettings(body) {
4284
- return {
4285
- enabled: pickBoolean(body, "enabled", true),
4286
- sound: pickBoolean(body, "sound", true),
4287
- follows: pickBoolean(body, "follows", true),
4288
- wallPosts: pickBoolean(body, "wallPosts", true),
4289
- likes: pickBoolean(body, "likes", true),
4290
- comments: pickBoolean(body, "comments", true),
4291
- mentions: pickBoolean(body, "mentions", true)
4292
- };
4530
+ const settings = {};
4531
+ for (const key of NOTIFICATION_SETTING_KEYS) settings[key] = pickBoolean(body, key, true);
4532
+ return settings;
4293
4533
  }
4294
4534
  var NotificationsResource = class extends BaseResource {
4535
+ /** Уведомления: `/api/notifications/`, пагинация по смещению. */
4536
+ #list = this.paginated({
4537
+ // Завершающий слэш обязателен: без него сервер отвечает ошибкой.
4538
+ path: () => "/api/notifications/",
4539
+ query: (p) => ({ limit: p.limit }),
4540
+ start: (p) => ({ offset: p.offset ?? 0 }),
4541
+ read: (body, state) => {
4542
+ const page = readOffsetPage(body, "notifications", state.offset ?? 0);
4543
+ return { ...page, items: page.items.map(normalizeNotification) };
4544
+ },
4545
+ mode: PaginationMode.Offset
4546
+ });
4295
4547
  /**
4296
4548
  * Загружает страницу уведомлений.
4297
4549
  *
4298
- * Пагинация здесь основана на смещении. Сайт итд.com оборачивает смещение в строку
4299
- * и притворяется, что это курсор; библиотека отдаёт честное число.
4550
+ * Пагинация здесь основана на смещении.
4300
4551
  *
4301
4552
  * @example
4302
4553
  * ```ts
@@ -4305,19 +4556,7 @@ var NotificationsResource = class extends BaseResource {
4305
4556
  * ```
4306
4557
  */
4307
4558
  list(params = {}) {
4308
- return this.#loadPage(params, params.offset ?? 0);
4309
- }
4310
- /** Общая загрузка страницы для {@link list} и {@link iterate}. */
4311
- async #loadPage(params, offset) {
4312
- const body = await this.http.request({
4313
- method: "GET",
4314
- // Завершающий слэш обязателен: без него сервер отвечает ошибкой.
4315
- path: "/api/notifications/",
4316
- query: { limit: params.limit, offset },
4317
- ...this.requestOptions(params)
4318
- });
4319
- const page = readOffsetPage(body, "notifications", offset);
4320
- return { ...page, items: page.items.map(normalizeNotification) };
4559
+ return this.#list.list(params);
4321
4560
  }
4322
4561
  /**
4323
4562
  * Перебирает уведомления.
@@ -4330,11 +4569,7 @@ var NotificationsResource = class extends BaseResource {
4330
4569
  * ```
4331
4570
  */
4332
4571
  iterate(params = {}) {
4333
- return this.paginate(
4334
- PaginationMode.Offset,
4335
- (state) => this.#loadPage(params, state.offset ?? 0),
4336
- { ...params, ...params.offset !== void 0 ? { start: { offset: params.offset } } : {} }
4337
- );
4572
+ return this.#list.iterate(params);
4338
4573
  }
4339
4574
  /** Загружает число непрочитанных уведомлений. */
4340
4575
  async count(options = {}) {
@@ -4394,43 +4629,143 @@ var NotificationsResource = class extends BaseResource {
4394
4629
  async getSettings(options = {}) {
4395
4630
  const body = await this.http.request({
4396
4631
  method: "GET",
4397
- path: "/api/notifications/settings",
4632
+ path: "/api/notifications/settings",
4633
+ ...this.requestOptions(options)
4634
+ });
4635
+ return readSettings(body);
4636
+ }
4637
+ /**
4638
+ * Обновляет настройки уведомлений.
4639
+ *
4640
+ * Отправляются только изменяемые поля, в том же виде, в каком сервер их возвращает.
4641
+ */
4642
+ async updateSettings(input, options = {}) {
4643
+ const payload = {};
4644
+ for (const key of NOTIFICATION_SETTING_KEYS) {
4645
+ const value = input[key];
4646
+ if (value !== void 0) payload[key] = value;
4647
+ }
4648
+ const body = await this.http.request({
4649
+ method: "PUT",
4650
+ path: "/api/notifications/settings",
4651
+ body: payload,
4652
+ ...this.requestOptions(options)
4653
+ });
4654
+ return readSettings(body);
4655
+ }
4656
+ };
4657
+
4658
+ // src/core/time.ts
4659
+ var NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
4660
+ function utcStampToIso(value) {
4661
+ const match = typeof value === "string" ? NAIVE_STAMP.exec(value) : null;
4662
+ if (!match) return value;
4663
+ const iso = `${match[1]}T${match[2]}Z`;
4664
+ return Number.isFinite(Date.parse(iso)) ? iso : value;
4665
+ }
4666
+
4667
+ // src/resources/platform.ts
4668
+ function normalizeStatus(body) {
4669
+ if (!isRecord(body) || !Array.isArray(body.services)) return body;
4670
+ return {
4671
+ ...body,
4672
+ services: body.services.map(
4673
+ (service) => typeof service?.last_checked === "string" ? { ...service, last_checked: utcStampToIso(service.last_checked) } : service
4674
+ )
4675
+ };
4676
+ }
4677
+ var PlatformResource = class extends BaseResource {
4678
+ /** Загружает журнал изменений. */
4679
+ async changelog(options = {}) {
4680
+ const body = await this.http.request({
4681
+ method: "GET",
4682
+ path: "/api/platform/changelog",
4683
+ ...this.requestOptions(options)
4684
+ });
4685
+ return Array.isArray(body) ? body : [];
4686
+ }
4687
+ /** Загружает анонсы платформы. */
4688
+ async announcements(options = {}) {
4689
+ const body = await this.http.request({
4690
+ method: "GET",
4691
+ path: "/api/platform/announcements",
4692
+ ...this.requestOptions(options)
4693
+ });
4694
+ return pickArray(body, "announcements");
4695
+ }
4696
+ /** Загружает баннер текущего события — виджет «портал». */
4697
+ portal(options = {}) {
4698
+ return this.http.request({
4699
+ method: "GET",
4700
+ path: "/api/v1/portal",
4398
4701
  ...this.requestOptions(options)
4399
4702
  });
4400
- return readSettings(body);
4401
4703
  }
4402
4704
  /**
4403
- * Обновляет настройки уведомлений.
4705
+ * Загружает состояние сервисов платформы за последние 90 суток.
4404
4706
  *
4405
- * Отправляются только изменяемые поля, в том же виде, в каком сервер их возвращает.
4707
+ * Идёт на хост `статус.итд.com` без авторизации. Ответ кэшируется сервером на минуту.
4708
+ * История по суткам приходит разреженной, ровный массив даёт `statusDays`.
4709
+ *
4710
+ * @example
4711
+ * ```ts
4712
+ * const status = await itd.platform.status();
4713
+ *
4714
+ * if (status.overall_status !== 'operational') {
4715
+ * const broken = status.services.filter((s) => s.current_status !== 'operational');
4716
+ * console.log('лежит:', broken.map((s) => s.name).join(', '));
4717
+ * }
4718
+ * ```
4406
4719
  */
4407
- async updateSettings(input, options = {}) {
4408
- const payload = {};
4409
- for (const key of [
4410
- "enabled",
4411
- "sound",
4412
- "follows",
4413
- "wallPosts",
4414
- "likes",
4415
- "comments",
4416
- "mentions"
4417
- ]) {
4418
- const value = input[key];
4419
- if (value !== void 0) payload[key] = value;
4420
- }
4720
+ async status(options = {}) {
4421
4721
  const body = await this.http.request({
4422
- method: "PUT",
4423
- path: "/api/notifications/settings",
4424
- body: payload,
4722
+ method: "GET",
4723
+ service: STATUS_SERVICE,
4724
+ path: "/api/status",
4425
4725
  ...this.requestOptions(options)
4426
4726
  });
4427
- return readSettings(body);
4727
+ return normalizeStatus(body);
4428
4728
  }
4429
4729
  };
4430
4730
 
4431
4731
  // src/resources/posts.ts
4732
+ function cursorStart(params) {
4733
+ return params.cursor ? { cursor: params.cursor } : {};
4734
+ }
4432
4735
  var PostsResource = class extends BaseResource {
4433
4736
  #uploadFiles;
4737
+ /** Лента: `/api/posts`, курсорная пагинация. */
4738
+ #feed = this.paginated({
4739
+ path: () => "/api/posts",
4740
+ query: (p) => ({ tab: p.tab, limit: p.limit }),
4741
+ start: cursorStart,
4742
+ read: (body) => readCursorPage(body, "posts"),
4743
+ mode: PaginationMode.Cursor
4744
+ });
4745
+ /** Стена пользователя: `/api/posts/user/{user}`. */
4746
+ #wall = this.paginated({
4747
+ path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}`,
4748
+ query: (p) => ({ limit: p.limit, sort: p.sort, pinnedPostId: p.pinnedPostId }),
4749
+ start: cursorStart,
4750
+ read: (body) => readCursorPage(body, "posts"),
4751
+ mode: PaginationMode.Cursor
4752
+ });
4753
+ /** Понравившиеся посты пользователя: `/api/posts/user/{user}/liked`. */
4754
+ #liked = this.paginated({
4755
+ path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}/liked`,
4756
+ query: (p) => ({ limit: p.limit }),
4757
+ start: cursorStart,
4758
+ read: (body) => readCursorPage(body, "posts"),
4759
+ mode: PaginationMode.Cursor
4760
+ });
4761
+ /** Комментарии к посту: курсор лежит рядом со списком, поэтому свой reader. */
4762
+ #comments = this.paginated({
4763
+ path: (p) => `/api/posts/${encodePathSegment(p.postId, "postId")}/comments`,
4764
+ query: (p) => ({ limit: p.limit, sort: p.sort }),
4765
+ start: cursorStart,
4766
+ read: (body) => readFlatCursorPage(body, "comments"),
4767
+ mode: PaginationMode.Cursor
4768
+ });
4434
4769
  constructor(http, deps) {
4435
4770
  super(http);
4436
4771
  this.#uploadFiles = deps.uploadFiles;
@@ -4444,14 +4779,8 @@ var PostsResource = class extends BaseResource {
4444
4779
  * const next = await itd.posts.list({ tab: FeedTab.Following, cursor: page.nextCursor ?? undefined });
4445
4780
  * ```
4446
4781
  */
4447
- async list(params = {}) {
4448
- const body = await this.http.request({
4449
- method: "GET",
4450
- path: "/api/posts",
4451
- query: { tab: params.tab, limit: params.limit, cursor: params.cursor },
4452
- ...this.requestOptions(params)
4453
- });
4454
- return readCursorPage(body, "posts");
4782
+ list(params = {}) {
4783
+ return this.#feed.list(params);
4455
4784
  }
4456
4785
  /**
4457
4786
  * Перебирает ленту, сама подставляя курсоры.
@@ -4464,19 +4793,7 @@ var PostsResource = class extends BaseResource {
4464
4793
  * ```
4465
4794
  */
4466
4795
  iterate(params = {}) {
4467
- return this.paginate(
4468
- PaginationMode.Cursor,
4469
- async (state) => {
4470
- const body = await this.http.request({
4471
- method: "GET",
4472
- path: "/api/posts",
4473
- query: withPageState({ tab: params.tab, limit: params.limit }, state),
4474
- ...this.requestOptions(params)
4475
- });
4476
- return readCursorPage(body, "posts");
4477
- },
4478
- { ...params, ...params.cursor ? { start: { cursor: params.cursor } } : {} }
4479
- );
4796
+ return this.#feed.iterate(params);
4480
4797
  }
4481
4798
  /**
4482
4799
  * Публикует пост.
@@ -4630,66 +4947,20 @@ var PostsResource = class extends BaseResource {
4630
4947
  *
4631
4948
  * Принимает и UUID, и имя пользователя.
4632
4949
  */
4633
- async byUser(user, params = {}) {
4634
- const body = await this.http.request({
4635
- method: "GET",
4636
- path: `/api/posts/user/${encodePathSegment(user, "user")}`,
4637
- query: {
4638
- limit: params.limit,
4639
- cursor: params.cursor,
4640
- sort: params.sort,
4641
- pinnedPostId: params.pinnedPostId
4642
- },
4643
- ...this.requestOptions(params)
4644
- });
4645
- return readCursorPage(body, "posts");
4950
+ byUser(user, params = {}) {
4951
+ return this.#wall.list({ ...params, user });
4646
4952
  }
4647
4953
  /** Перебирает стену пользователя. Что именно в неё входит — см. {@link byUser}. */
4648
4954
  iterateByUser(user, params = {}) {
4649
- const path = `/api/posts/user/${encodePathSegment(user, "user")}`;
4650
- return this.paginate(
4651
- PaginationMode.Cursor,
4652
- async (state) => {
4653
- const body = await this.http.request({
4654
- method: "GET",
4655
- path,
4656
- query: withPageState(
4657
- { limit: params.limit, sort: params.sort, pinnedPostId: params.pinnedPostId },
4658
- state
4659
- ),
4660
- ...this.requestOptions(params)
4661
- });
4662
- return readCursorPage(body, "posts");
4663
- },
4664
- { ...params, ...params.cursor ? { start: { cursor: params.cursor } } : {} }
4665
- );
4955
+ return this.#wall.iterate({ ...params, user });
4666
4956
  }
4667
4957
  /** Загружает страницу постов, которые пользователь отметил реакцией. */
4668
- async likedByUser(user, params = {}) {
4669
- const body = await this.http.request({
4670
- method: "GET",
4671
- path: `/api/posts/user/${encodePathSegment(user, "user")}/liked`,
4672
- query: { limit: params.limit, cursor: params.cursor },
4673
- ...this.requestOptions(params)
4674
- });
4675
- return readCursorPage(body, "posts");
4958
+ likedByUser(user, params = {}) {
4959
+ return this.#liked.list({ ...params, user });
4676
4960
  }
4677
4961
  /** Перебирает посты, которые пользователь отметил реакцией. */
4678
4962
  iterateLikedByUser(user, params = {}) {
4679
- const path = `/api/posts/user/${encodePathSegment(user, "user")}/liked`;
4680
- return this.paginate(
4681
- PaginationMode.Cursor,
4682
- async (state) => {
4683
- const body = await this.http.request({
4684
- method: "GET",
4685
- path,
4686
- query: withPageState({ limit: params.limit }, state),
4687
- ...this.requestOptions(params)
4688
- });
4689
- return readCursorPage(body, "posts");
4690
- },
4691
- { ...params, ...params.cursor ? { start: { cursor: params.cursor } } : {} }
4692
- );
4963
+ return this.#liked.iterate({ ...params, user });
4693
4964
  }
4694
4965
  /**
4695
4966
  * Загружает страницу комментариев к посту.
@@ -4697,31 +4968,12 @@ var PostsResource = class extends BaseResource {
4697
4968
  * У этого эндпоинта курсор и признак продолжения лежат рядом со списком, а не внутри
4698
4969
  * объекта `pagination`, как у остальных, — разница скрыта внутри.
4699
4970
  */
4700
- async comments(postId, params = {}) {
4701
- const body = await this.http.request({
4702
- method: "GET",
4703
- path: `/api/posts/${encodePathSegment(postId, "postId")}/comments`,
4704
- query: { limit: params.limit, cursor: params.cursor, sort: params.sort },
4705
- ...this.requestOptions(params)
4706
- });
4707
- return readFlatCursorPage(body, "comments");
4971
+ comments(postId, params = {}) {
4972
+ return this.#comments.list({ ...params, postId });
4708
4973
  }
4709
4974
  /** Перебирает комментарии к посту. */
4710
4975
  iterateComments(postId, params = {}) {
4711
- const path = `/api/posts/${encodePathSegment(postId, "postId")}/comments`;
4712
- return this.paginate(
4713
- PaginationMode.Cursor,
4714
- async (state) => {
4715
- const body = await this.http.request({
4716
- method: "GET",
4717
- path,
4718
- query: withPageState({ limit: params.limit, sort: params.sort }, state),
4719
- ...this.requestOptions(params)
4720
- });
4721
- return readFlatCursorPage(body, "comments");
4722
- },
4723
- { ...params, ...params.cursor ? { start: { cursor: params.cursor } } : {} }
4724
- );
4976
+ return this.#comments.iterate({ ...params, postId });
4725
4977
  }
4726
4978
  /**
4727
4979
  * Комментирует пост.
@@ -4766,6 +5018,122 @@ var PostsResource = class extends BaseResource {
4766
5018
  }
4767
5019
  };
4768
5020
 
5021
+ // src/resources/reports.ts
5022
+ var ReportsResource = class extends BaseResource {
5023
+ /**
5024
+ * Отправляет жалобу.
5025
+ *
5026
+ * Повторная жалоба на тот же объект отклоняется сервером с сообщением
5027
+ * «Вы уже отправляли жалобу на этот контент».
5028
+ *
5029
+ * @example
5030
+ * ```ts
5031
+ * await itd.reports.create(report.post(postId).reason('spam'));
5032
+ * await itd.reports.create({ targetType: 'user', targetId, reason: 'fraud' });
5033
+ * ```
5034
+ */
5035
+ create(input, options = {}) {
5036
+ const data = resolveReport(input);
5037
+ return this.http.request({
5038
+ method: "POST",
5039
+ path: "/api/reports",
5040
+ body: data,
5041
+ ...this.requestOptions(options)
5042
+ });
5043
+ }
5044
+ };
5045
+
5046
+ // src/resources/search.ts
5047
+ var SearchResource = class extends BaseResource {
5048
+ /**
5049
+ * Ищет пользователей и хэштеги одним запросом.
5050
+ *
5051
+ * @example
5052
+ * ```ts
5053
+ * const { users, hashtags } = await itd.search.all('арт');
5054
+ * ```
5055
+ */
5056
+ async all(query, options = {}) {
5057
+ const body = await this.http.request({
5058
+ method: "GET",
5059
+ path: "/api/search",
5060
+ query: { q: query },
5061
+ ...this.requestOptions(options)
5062
+ });
5063
+ return {
5064
+ users: pickArray(body, "users"),
5065
+ hashtags: pickArray(body, "hashtags")
5066
+ };
5067
+ }
5068
+ };
5069
+
5070
+ // src/resources/subscription.ts
5071
+ var SubscriptionResource = class extends BaseResource {
5072
+ /** Загружает состояние подписки и её цену. */
5073
+ status(options = {}) {
5074
+ return this.http.request({
5075
+ method: "GET",
5076
+ // Завершающий слэш обязателен.
5077
+ path: "/api/v1/subscription/",
5078
+ ...this.requestOptions(options)
5079
+ });
5080
+ }
5081
+ /**
5082
+ * Запускает оплату подписки.
5083
+ *
5084
+ * Форма ответа в документации API не описана, поэтому тип результата не уточняется.
5085
+ */
5086
+ pay(options = {}) {
5087
+ return this.http.request({
5088
+ method: "POST",
5089
+ path: "/api/v1/subscription/pay",
5090
+ ...this.requestOptions(options)
5091
+ });
5092
+ }
5093
+ /** Включает или отключает автопродление. */
5094
+ setAutoRenewal(enabled, options = {}) {
5095
+ return this.http.request({
5096
+ method: "POST",
5097
+ path: "/api/v1/subscription/auto-renewal",
5098
+ body: { enabled },
5099
+ ...this.requestOptions(options)
5100
+ });
5101
+ }
5102
+ /** Запускает привязку карты. */
5103
+ bindCard(options = {}) {
5104
+ return this.http.request({
5105
+ method: "POST",
5106
+ path: "/api/v1/subscription/bind-card",
5107
+ ...this.requestOptions(options)
5108
+ });
5109
+ }
5110
+ /** Загружает список способов оплаты. Пустой массив, если карт нет. */
5111
+ async methods(options = {}) {
5112
+ const body = await this.http.request({
5113
+ method: "GET",
5114
+ path: "/api/v1/subscription/methods",
5115
+ ...this.requestOptions(options)
5116
+ });
5117
+ return Array.isArray(body) ? body : [];
5118
+ }
5119
+ /** Делает способ оплаты основным. */
5120
+ setDefaultMethod(methodId, options = {}) {
5121
+ return this.http.request({
5122
+ method: "POST",
5123
+ path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}/default`,
5124
+ ...this.requestOptions(options)
5125
+ });
5126
+ }
5127
+ /** Удаляет способ оплаты. */
5128
+ removeMethod(methodId, options = {}) {
5129
+ return this.http.request({
5130
+ method: "DELETE",
5131
+ path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}`,
5132
+ ...this.requestOptions(options)
5133
+ });
5134
+ }
5135
+ };
5136
+
4769
5137
  // src/resources/telemetry.ts
4770
5138
  var TelemetryResource = class extends BaseResource {
4771
5139
  /** Идентификатор сессии телеметрии, общий для всех событий этого объекта. */
@@ -4832,6 +5200,21 @@ var TelemetryResource = class extends BaseResource {
4832
5200
 
4833
5201
  // src/resources/users.ts
4834
5202
  var UsersResource = class extends BaseResource {
5203
+ /**
5204
+ * Списки пользователей: подписчики, подписки, заблокированные.
5205
+ *
5206
+ * Путь приходит в параметрах — так один описатель обслуживает все три эндпоинта. Имена
5207
+ * полей перечислены с запасом: списки приходят под `users`, но альтернативное имя ничего
5208
+ * не стоит и спасает, если эндпоинт назовёт список по-своему. `page` уходит в запрос, хотя
5209
+ * сервер его сейчас не читает (см. {@link followers}): когда починят — заработает само.
5210
+ */
5211
+ #userList = this.paginated({
5212
+ path: (p) => p.path,
5213
+ query: (p) => ({ limit: p.limit }),
5214
+ start: (p) => p.page !== void 0 ? { page: p.page } : {},
5215
+ read: (body) => readPagedPage(body, "users", "followers", "following", "blocked"),
5216
+ mode: PaginationMode.Page
5217
+ });
4835
5218
  /** Загружает свой профиль — с подпиской и признаком подтверждённого телефона. */
4836
5219
  me(options = {}) {
4837
5220
  return this.http.request({
@@ -5077,48 +5460,47 @@ var UsersResource = class extends BaseResource {
5077
5460
  ...this.requestOptions(options)
5078
5461
  });
5079
5462
  }
5080
- /**
5081
- * Загружает одну страницу списка пользователей.
5082
- *
5083
- * Имена полей перечислены с запасом: списки подписчиков и заблокированных приходят
5084
- * под `users`, но альтернативное имя ничего не стоит и спасает, если эндпоинт назовёт
5085
- * список по-своему.
5086
- *
5087
- * `page` уходит в запрос, хотя сервер его сейчас не читает (см. {@link followers}):
5088
- * когда пагинацию починят, работать начнёт само.
5089
- */
5090
- async #loadUserPage(path, params, state) {
5091
- const body = await this.http.request({
5463
+ #userPage(path, params) {
5464
+ return this.#userList.list({ ...params, path });
5465
+ }
5466
+ #userPaginator(path, params) {
5467
+ return this.#userList.iterate({ ...params, path });
5468
+ }
5469
+ };
5470
+
5471
+ // src/resources/verification.ts
5472
+ var VerificationResource = class extends BaseResource {
5473
+ /** Загружает статус заявки. Значение `none` означает, что заявка не подавалась. */
5474
+ status(options = {}) {
5475
+ return this.http.request({
5092
5476
  method: "GET",
5093
- path,
5094
- query: withPageState({ limit: params.limit }, state),
5095
- ...this.requestOptions(params)
5477
+ path: "/api/verification/status",
5478
+ ...this.requestOptions(options)
5096
5479
  });
5097
- return readPagedPage(body, "users", "followers", "following", "blocked");
5098
5480
  }
5099
- #userPage(path, params) {
5100
- return this.#loadUserPage(path, params, {
5101
- ...params.page !== void 0 ? { page: params.page } : {}
5481
+ /** Подаёт заявку на верификацию с видео. */
5482
+ submit(videoUrl, options = {}) {
5483
+ return this.http.request({
5484
+ method: "POST",
5485
+ path: "/api/verification/submit",
5486
+ body: { videoUrl },
5487
+ ...this.requestOptions(options)
5102
5488
  });
5103
5489
  }
5104
- #userPaginator(path, params) {
5105
- return this.paginate(
5106
- PaginationMode.Page,
5107
- (state) => this.#loadUserPage(path, params, state),
5108
- // Без `start` перебор начинался бы с первой страницы, молча игнорируя `page`.
5109
- { ...params, ...params.page !== void 0 ? { start: { page: params.page } } : {} }
5110
- );
5111
- }
5112
5490
  };
5113
5491
 
5114
5492
  // src/client.ts
5115
- var ItdClient = class {
5493
+ var ItdClient = class _ItdClient {
5116
5494
  #config;
5117
5495
  #http;
5496
+ #transport;
5118
5497
  #authManager;
5119
5498
  #jar;
5120
- #queue;
5499
+ #queues;
5121
5500
  #plugins = new PluginRegistry();
5501
+ #services;
5502
+ /** Порождённые потоки уведомлений — чтобы `close()` мог закрыть их разом. */
5503
+ #streams = /* @__PURE__ */ new Set();
5122
5504
  /** Авторизация, сессии и пароли. */
5123
5505
  auth;
5124
5506
  /** Профили, подписки, блокировки, приватность. */
@@ -5149,26 +5531,65 @@ var ItdClient = class {
5149
5531
  * @experimental Недокументированные эндпоинты. Библиотека никогда не отправляет их сама.
5150
5532
  */
5151
5533
  telemetry;
5152
- constructor(options = {}) {
5153
- this.#config = resolveConfig(options);
5534
+ constructor(options = {}, internals = {}) {
5535
+ const config = resolveConfig(options);
5536
+ this.#config = config;
5154
5537
  this.#jar = new CookieJar();
5155
- this.#http = new HttpClient(this.#config);
5156
- this.#authManager = new AuthManager(this.#config, this.#http, this.#jar);
5157
- this.#queue = this.#config.rateLimit ? new RequestQueue(this.#config.rateLimit) : void 0;
5158
- this.#http.usePlugins(this.#plugins);
5159
- this.#http.setCollaborators({
5160
- getAuthHeaders: () => this.#authManager.getAuthHeaders(),
5161
- getDeviceId: () => this.#authManager.getDeviceId(),
5162
- onUnauthorized: () => this.#authManager.onUnauthorized(),
5163
- getCookieHeader: (url) => this.#jar.getHeader(url),
5164
- saveCookies: (url, response) => this.#jar.setFromResponse(url, response),
5165
- ...this.#queue ? { schedule: this.#queue.schedule.bind(this.#queue) } : {},
5166
- // Планировщик нужен, даже когда обычные повторы выключены: лимит частоты
5167
- // живёт по своим правилам и настраивается отдельно, в `rateLimit`.
5168
- ...this.#config.retry || this.#config.rateLimit ? { nextRetryDelay: this.#createRetryScheduler() } : {},
5169
- ...this.#queue && this.#config.rateLimit?.respectHeaders ? { onRateLimit: this.#throttleByHeaders.bind(this) } : {}
5538
+ this.#services = new ServiceRegistry(config.baseUrl);
5539
+ for (const service of BUILT_IN_SERVICES) this.#services.define(service);
5540
+ for (const service of config.services) this.#services.define(service);
5541
+ const queues = config.rateLimit ? new RequestQueuePool(config.rateLimit) : void 0;
5542
+ this.#queues = queues;
5543
+ let authManager;
5544
+ const transport = new Transport(config, {
5545
+ cookies: config.useCookieJar ? this.#jar : void 0,
5546
+ getDeviceId: () => authManager.getDeviceId(),
5547
+ onRateLimit: queues && config.rateLimit?.respectHeaders ? (limit, remaining, request) => this.#throttleByHeaders(limit, remaining, request) : void 0
5548
+ });
5549
+ this.#transport = transport;
5550
+ const pluginsLayer = createPluginsMiddleware(this.#plugins);
5551
+ const retriesLayer = createRetryMiddleware({
5552
+ retry: config.retry,
5553
+ rateLimitDelays: config.rateLimit?.retryDelays ?? [],
5554
+ pauseQueue: queues ? (ms, request) => queues.for(request.service).pause(ms) : void 0,
5555
+ hooks: config.hooks,
5556
+ logger: config.logger,
5557
+ buildUrl: (request) => transport.buildUrl(request)
5170
5558
  });
5171
- this.files = new FilesResource(this.#http);
5559
+ const authRetry = config.retry ? {
5560
+ attempts: config.retry.attempts,
5561
+ baseDelay: config.retry.baseDelay,
5562
+ maxDelay: config.retry.maxDelay,
5563
+ jitter: config.retry.jitter,
5564
+ retryWrites: true,
5565
+ ...config.retry.shouldRetry ? { shouldRetry: config.retry.shouldRetry } : {}
5566
+ } : void 0;
5567
+ const authPipeline = composePipeline([pluginsLayer, retriesLayer], transport.send);
5568
+ const authHandler = (request) => authRetry && request.retry === void 0 ? authPipeline({ ...request, retry: authRetry }) : authPipeline(request);
5569
+ authManager = new AuthManager(config, authHandler, this.#jar);
5570
+ this.#authManager = authManager;
5571
+ const middlewares = [];
5572
+ if (queues) {
5573
+ middlewares.push(
5574
+ createQueueMiddleware((request, task) => queues.for(request.service).schedule(task))
5575
+ );
5576
+ }
5577
+ middlewares.push(pluginsLayer);
5578
+ middlewares.push(createServicesMiddleware(this.#services));
5579
+ middlewares.push(retriesLayer);
5580
+ middlewares.push(
5581
+ createAuthMiddleware({
5582
+ getAuthHeaders: () => authManager.getAuthHeaders(),
5583
+ onUnauthorized: () => authManager.onUnauthorized(),
5584
+ autoRefresh: config.autoRefresh
5585
+ })
5586
+ );
5587
+ const handler = composePipeline(middlewares, transport.send);
5588
+ this.#http = new HttpClient({ handler, plugins: this.#plugins, baseUrl: config.baseUrl });
5589
+ this.files = new FilesResource(
5590
+ this.#http,
5591
+ internals.fileReader ? { readFile: internals.fileReader } : {}
5592
+ );
5172
5593
  const uploadFiles = (files, requestOptions) => this.files.uploadMany(files, requestOptions ?? {});
5173
5594
  this.auth = new AuthResource(this.#http, { auth: this.#authManager });
5174
5595
  this.users = new UsersResource(this.#http);
@@ -5212,7 +5633,7 @@ var ItdClient = class {
5212
5633
  *
5213
5634
  * @example
5214
5635
  * ```ts
5215
- * import { crypt } from 'itd-api-crypto';
5636
+ * import { crypt } from '@itd-api/crypto';
5216
5637
  *
5217
5638
  * itd.use(crypt());
5218
5639
  * await itd.posts.create({ content: 'секрет' }, { encrypt: 'invis' });
@@ -5222,6 +5643,41 @@ var ItdClient = class {
5222
5643
  this.#plugins.add(plugin, { baseUrl: this.#config.baseUrl, logger: this.#config.logger });
5223
5644
  return this;
5224
5645
  }
5646
+ /**
5647
+ * Регистрирует сервис платформы — домен, отличный от основного.
5648
+ *
5649
+ * Запросы с `{ service: 'имя' }` уходят на его хост с его заголовками. То же самое умеет
5650
+ * опция `services` конструктора. Занятое имя не переопределяется — ни своё, ни встроенное:
5651
+ * разовому запросу хост задаётся полем `baseUrl`.
5652
+ *
5653
+ * Заголовок авторизации по умолчанию уходит только своим — домену клиента и его
5654
+ * поддоменам. Стороннему хосту токен нужно разрешить явно: `auth: true`.
5655
+ *
5656
+ * @throws {ItdConfigError} если определение неверно или имя уже занято
5657
+ *
5658
+ * @example Сервис платформы на поддомене — токен уходит сам
5659
+ * ```ts
5660
+ * itd.defineService({
5661
+ * name: 'pb',
5662
+ * baseUrl: 'https://pbapi.xn--d1ah4a.com',
5663
+ * headers: { Referer: 'https://pixel.xn--d1ah4a.com/' },
5664
+ * });
5665
+ *
5666
+ * await itd.request({ method: 'GET', service: 'pb', path: '/api/pixel-info' });
5667
+ * ```
5668
+ */
5669
+ defineService(definition) {
5670
+ this.#services.define(definition);
5671
+ return this;
5672
+ }
5673
+ /**
5674
+ * Базовый URL зарегистрированного сервиса.
5675
+ *
5676
+ * @throws {ItdConfigError} если сервис не зарегистрирован
5677
+ */
5678
+ serviceBaseUrl(name) {
5679
+ return this.#services.resolveBaseUrl(name);
5680
+ }
5225
5681
  /**
5226
5682
  * Подписывается на события авторизации.
5227
5683
  *
@@ -5258,17 +5714,52 @@ var ItdClient = class {
5258
5714
  * ```
5259
5715
  */
5260
5716
  realtime(options = {}) {
5261
- return new ItdRealtime(
5717
+ let stream;
5718
+ stream = new ItdRealtime(
5262
5719
  {
5263
5720
  baseUrl: this.#config.baseUrl,
5264
5721
  fetch: this.#config.fetch,
5722
+ baseHeaders: (url) => this.#transport.platformHeaders(url),
5265
5723
  getToken: () => this.#authManager.getAccessToken(),
5266
5724
  refresh: () => this.#authManager.onUnauthorized(),
5267
5725
  fetchUnreadCount: () => this.notifications.count(),
5726
+ onClose: () => this.#streams.delete(stream),
5268
5727
  logger: this.#config.logger
5269
5728
  },
5270
5729
  options
5271
5730
  );
5731
+ this.#streams.add(stream);
5732
+ return stream;
5733
+ }
5734
+ /**
5735
+ * Освобождает ресурсы клиента: останавливает очередь запросов (снимает отложенные паузы)
5736
+ * и закрывает все потоки уведомлений, созданные через {@link realtime}.
5737
+ *
5738
+ * После вызова клиентом можно пользоваться снова — новые запросы поднимут всё заново,
5739
+ * но уже созданные потоки останутся закрытыми.
5740
+ *
5741
+ * @example
5742
+ * ```ts
5743
+ * await using itd = new ItdClient({ auth: token });
5744
+ * // …работа…
5745
+ * // close() вызовется сам на выходе из блока
5746
+ * ```
5747
+ */
5748
+ async close() {
5749
+ for (const stream of this.#streams) stream.disconnect();
5750
+ this.#streams.clear();
5751
+ this.#queues?.stop();
5752
+ }
5753
+ /** Позволяет использовать клиент с `await using`. */
5754
+ [Symbol.asyncDispose]() {
5755
+ return this.close();
5756
+ }
5757
+ static {
5758
+ if (typeof Symbol.asyncDispose !== "symbol") {
5759
+ const prototype = _ItdClient.prototype;
5760
+ prototype[/* @__PURE__ */ Symbol.for("Symbol.asyncDispose")] = prototype.undefined;
5761
+ delete prototype.undefined;
5762
+ }
5272
5763
  }
5273
5764
  /** Текущая сессия целиком — чтобы сохранить её самостоятельно. */
5274
5765
  getSession() {
@@ -5278,16 +5769,6 @@ var ItdClient = class {
5278
5769
  setSession(session) {
5279
5770
  return this.#authManager.setSession(session);
5280
5771
  }
5281
- /**
5282
- * Подключает чтение файлов с диска.
5283
- *
5284
- * Вызывается из `itd-api/node`; напрямую обычно не нужно.
5285
- *
5286
- * @internal
5287
- */
5288
- setFileReader(readFile) {
5289
- this.files.setFileReader(readFile);
5290
- }
5291
5772
  /**
5292
5773
  * Придерживает очередь, когда лимит сервера исчерпан.
5293
5774
  *
@@ -5299,43 +5780,15 @@ var ItdClient = class {
5299
5780
  * Смысл этой паузы прежде всего в том, чтобы при работе в несколько потоков остальные
5300
5781
  * запросы не улетели в стену все разом.
5301
5782
  */
5302
- #throttleByHeaders(limit, remaining) {
5783
+ #throttleByHeaders(limit, remaining, request) {
5303
5784
  if (remaining === void 0 || remaining > 0) return;
5304
5785
  const first = this.#config.rateLimit?.retryDelays[0];
5305
5786
  if (first === void 0) return;
5306
- this.#queue?.pause(first);
5787
+ this.#queues?.for(request.service).pause(first);
5307
5788
  this.#config.logger?.debug(
5308
5789
  `\u043B\u0438\u043C\u0438\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u0438\u0441\u0447\u0435\u0440\u043F\u0430\u043D (${remaining} \u0438\u0437 ${limit ?? "?"}), \u043E\u0447\u0435\u0440\u0435\u0434\u044C \u0436\u0434\u0451\u0442 ${first} \u043C\u0441`
5309
5790
  );
5310
5791
  }
5311
- /**
5312
- * Собирает планировщик повторов и связывает его с очередью.
5313
- *
5314
- * Ответ `429` обрабатывается отдельно от прочих ошибок. Причина в том, что сервер
5315
- * не присылает `Retry-After` и не сообщает время сброса окна: экспоненциальный откат
5316
- * в сотни миллисекунд здесь бесполезен, а окно измеряется десятками секунд. Вместо
5317
- * расчёта берётся лестница пауз `rateLimit.retryDelays`, и она не зависит
5318
- * от `retry.attempts`, у которого совсем другая задача.
5319
- *
5320
- * Пауза накладывается на всю очередь: иначе остальные запросы продолжат добивать API,
5321
- * пока первый ждёт.
5322
- */
5323
- #createRetryScheduler() {
5324
- const retry = this.#config.retry;
5325
- const scheduler = retry ? createRetryScheduler(retry) : void 0;
5326
- const queue = this.#queue;
5327
- const delays = this.#config.rateLimit?.retryDelays ?? [];
5328
- return (error, attempt, method) => {
5329
- if (isItdRateLimitError(error)) {
5330
- const wait = error.retryAfter ?? delays[attempt - 1];
5331
- if (wait === void 0) return void 0;
5332
- queue?.pause(wait);
5333
- this.#config.logger?.debug(`\u043B\u0438\u043C\u0438\u0442 \u0447\u0430\u0441\u0442\u043E\u0442\u044B, \u043F\u043E\u043F\u044B\u0442\u043A\u0430 ${attempt + 1} \u0447\u0435\u0440\u0435\u0437 ${wait} \u043C\u0441`);
5334
- return wait;
5335
- }
5336
- return scheduler?.(error, attempt, method);
5337
- };
5338
- }
5339
5792
  };
5340
5793
  function createClient(options = {}) {
5341
5794
  return new ItdClient(options);
@@ -5442,11 +5895,18 @@ function resolveNotificationUrl(notification) {
5442
5895
  function isMyProfile(profile) {
5443
5896
  return "subscription" in profile;
5444
5897
  }
5898
+ var STATUS_WINDOW_DAYS = 90;
5445
5899
  function toDate(value) {
5446
5900
  if (!value) return null;
5447
5901
  const date = new Date(value);
5448
5902
  return Number.isFinite(date.getTime()) ? date : null;
5449
5903
  }
5904
+ function statusDays(service) {
5905
+ return Array.from(
5906
+ { length: STATUS_WINDOW_DAYS },
5907
+ (_, index) => service.days[String(index)] ?? null
5908
+ );
5909
+ }
5450
5910
 
5451
5911
  exports.ALLOWED_MIME_TYPES = ALLOWED_MIME_TYPES;
5452
5912
  exports.AUDIO_MIME_TYPES = AUDIO_MIME_TYPES;
@@ -5454,14 +5914,18 @@ exports.AUTH_FLAG_COOKIE = AUTH_FLAG_COOKIE;
5454
5914
  exports.AUTH_PATHS = AUTH_PATHS;
5455
5915
  exports.AccessType = AccessType;
5456
5916
  exports.AttachmentType = AttachmentType;
5917
+ exports.BUILT_IN_SERVICES = BUILT_IN_SERVICES;
5457
5918
  exports.CommentSort = CommentSort;
5458
5919
  exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
5920
+ exports.DEFAULT_STATUS_BASE_URL = DEFAULT_STATUS_BASE_URL;
5459
5921
  exports.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
5922
+ exports.DEFAULT_UPLOAD_TIMEOUT = DEFAULT_UPLOAD_TIMEOUT;
5460
5923
  exports.DEFAULT_USER_AGENT = DEFAULT_USER_AGENT;
5461
5924
  exports.DEVICE_ID_HEADER = DEVICE_ID_HEADER;
5462
5925
  exports.DetectedRuntime = DetectedRuntime;
5463
5926
  exports.FeedTab = FeedTab;
5464
5927
  exports.IMAGE_MIME_TYPES = IMAGE_MIME_TYPES;
5928
+ exports.IncidentKind = IncidentKind;
5465
5929
  exports.InteractionType = InteractionType;
5466
5930
  exports.ItdAbortError = ItdAbortError;
5467
5931
  exports.ItdApiError = ItdApiError;
@@ -5496,12 +5960,16 @@ exports.RECONNECT_BACKOFF = RECONNECT_BACKOFF;
5496
5960
  exports.RECONNECT_JITTER = RECONNECT_JITTER;
5497
5961
  exports.REFRESH_COOKIE = REFRESH_COOKIE;
5498
5962
  exports.REFRESH_COOKIE_PATH = REFRESH_COOKIE_PATH;
5963
+ exports.REQUEST_OPTION_KEYS = REQUEST_OPTION_KEYS;
5499
5964
  exports.RealtimeStatus = RealtimeStatus;
5500
5965
  exports.RealtimeTransportKind = RealtimeTransportKind;
5501
5966
  exports.ReportReason = ReportReason;
5502
5967
  exports.ReportTargetType = ReportTargetType;
5503
5968
  exports.RuntimeMode = RuntimeMode;
5969
+ exports.STATUS_SERVICE = STATUS_SERVICE;
5504
5970
  exports.STREAM_PATH = STREAM_PATH;
5971
+ exports.ServiceRegistry = ServiceRegistry;
5972
+ exports.ServiceState = ServiceState;
5505
5973
  exports.SignInStatus = SignInStatus;
5506
5974
  exports.SpanType = SpanType;
5507
5975
  exports.TURNSTILE_SITE_KEY = TURNSTILE_SITE_KEY;
@@ -5528,6 +5996,7 @@ exports.isItdServerError = isItdServerError;
5528
5996
  exports.isItdValidationError = isItdValidationError;
5529
5997
  exports.isKnownNotificationType = isKnownNotificationType;
5530
5998
  exports.isMyProfile = isMyProfile;
5999
+ exports.mapPage = mapPage;
5531
6000
  exports.normalizeNotification = normalizeNotification;
5532
6001
  exports.poll = poll;
5533
6002
  exports.post = post;
@@ -5535,6 +6004,8 @@ exports.readNotificationEvent = readNotificationEvent;
5535
6004
  exports.readUnreadCountEvent = readUnreadCountEvent;
5536
6005
  exports.report = report;
5537
6006
  exports.resolveNotificationUrl = resolveNotificationUrl;
6007
+ exports.statusDays = statusDays;
5538
6008
  exports.toDate = toDate;
5539
- //# sourceMappingURL=chunk-3RNNZJZ4.cjs.map
5540
- //# sourceMappingURL=chunk-3RNNZJZ4.cjs.map
6009
+ exports.utcStampToIso = utcStampToIso;
6010
+ //# sourceMappingURL=chunk-HTF2MOM4.cjs.map
6011
+ //# sourceMappingURL=chunk-HTF2MOM4.cjs.map