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