itd-api 0.7.0 → 0.7.1

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.
Files changed (89) hide show
  1. package/dist/index.cjs +465 -9804
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +231 -4950
  4. package/dist/index.d.ts +231 -4950
  5. package/dist/index.js +331 -9670
  6. package/dist/index.js.map +1 -1
  7. package/dist/{node.cjs → node/index.cjs} +16 -14
  8. package/dist/node/index.cjs.map +1 -0
  9. package/dist/{node.d.cts → node/index.d.cts} +4 -3
  10. package/dist/{node.d.ts → node/index.d.ts} +4 -3
  11. package/dist/{node.js → node/index.js} +5 -3
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/realtime/index.cjs +165 -0
  14. package/dist/realtime/index.cjs.map +1 -0
  15. package/dist/realtime/index.d.cts +51 -0
  16. package/dist/realtime/index.d.ts +51 -0
  17. package/dist/realtime/index.js +120 -0
  18. package/dist/realtime/index.js.map +1 -0
  19. package/dist/rest/index.cjs +334 -0
  20. package/dist/rest/index.cjs.map +1 -0
  21. package/dist/rest/index.d.cts +138 -0
  22. package/dist/rest/index.d.ts +138 -0
  23. package/dist/rest/index.js +238 -0
  24. package/dist/rest/index.js.map +1 -0
  25. package/dist/shared/auth-provider-BfogACAb.js +91 -0
  26. package/dist/shared/auth-provider-BfogACAb.js.map +1 -0
  27. package/dist/shared/auth-provider-CTJkKfgy.cjs +108 -0
  28. package/dist/shared/auth-provider-CTJkKfgy.cjs.map +1 -0
  29. package/dist/shared/contracts-BoT7msmq.d.cts +84 -0
  30. package/dist/shared/contracts-BoT7msmq.d.ts +84 -0
  31. package/dist/{multi-storage-Bf84xiO8.cjs → shared/cookies-DZwFq6kr.cjs} +98 -429
  32. package/dist/shared/cookies-DZwFq6kr.cjs.map +1 -0
  33. package/dist/{multi-storage-BUZaLAPO.js → shared/cookies-tX2sNwxb.js} +99 -352
  34. package/dist/shared/cookies-tX2sNwxb.js.map +1 -0
  35. package/dist/{storage-IHdXw52v.js → shared/errors-Bhrd2fJd.js} +2 -229
  36. package/dist/shared/errors-Bhrd2fJd.js.map +1 -0
  37. package/dist/{storage-DnzZPS_9.cjs → shared/errors-DfU8M5eS.cjs} +1 -288
  38. package/dist/shared/errors-DfU8M5eS.cjs.map +1 -0
  39. package/dist/shared/multi-storage--yTEqiod.cjs +150 -0
  40. package/dist/shared/multi-storage--yTEqiod.cjs.map +1 -0
  41. package/dist/shared/multi-storage-CjAPB5Kq.d.cts +72 -0
  42. package/dist/shared/multi-storage-CkvTUC5m.js +121 -0
  43. package/dist/shared/multi-storage-CkvTUC5m.js.map +1 -0
  44. package/dist/shared/multi-storage-DccjD7Ww.d.ts +72 -0
  45. package/dist/shared/options-Dg5N3r1V.cjs +189 -0
  46. package/dist/shared/options-Dg5N3r1V.cjs.map +1 -0
  47. package/dist/shared/options-DtATYdLr.js +142 -0
  48. package/dist/shared/options-DtATYdLr.js.map +1 -0
  49. package/dist/shared/render-DMp_3Nzk.d.cts +2250 -0
  50. package/dist/shared/render-DZrxhC5_.d.ts +2250 -0
  51. package/dist/shared/render-DyeHJNBw.cjs +4147 -0
  52. package/dist/shared/render-DyeHJNBw.cjs.map +1 -0
  53. package/dist/shared/render-vtLixIiU.js +3992 -0
  54. package/dist/shared/render-vtLixIiU.js.map +1 -0
  55. package/dist/shared/storage-BPJR_k4-.cjs +290 -0
  56. package/dist/shared/storage-BPJR_k4-.cjs.map +1 -0
  57. package/dist/{storage-BqMxs76Y.d.ts → shared/storage-C_eICCep.d.cts} +2 -2
  58. package/dist/{storage-BqMxs76Y.d.cts → shared/storage-C_eICCep.d.ts} +2 -2
  59. package/dist/shared/storage-D86edNCB.js +231 -0
  60. package/dist/shared/storage-D86edNCB.js.map +1 -0
  61. package/dist/shared/url-B6-bXHKt.d.cts +2083 -0
  62. package/dist/shared/url-B6-bXHKt.d.ts +2083 -0
  63. package/dist/shared/url-CYXgxqGx.js +3673 -0
  64. package/dist/shared/url-CYXgxqGx.js.map +1 -0
  65. package/dist/shared/url-yjl2c8Ie.cjs +4032 -0
  66. package/dist/shared/url-yjl2c8Ie.cjs.map +1 -0
  67. package/dist/shared/websocket-BMtihD56.d.ts +562 -0
  68. package/dist/shared/websocket-CbzB1Leq.js +1874 -0
  69. package/dist/shared/websocket-CbzB1Leq.js.map +1 -0
  70. package/dist/shared/websocket-D1p32SB2.d.cts +562 -0
  71. package/dist/shared/websocket-sYlynrr0.cjs +1945 -0
  72. package/dist/shared/websocket-sYlynrr0.cjs.map +1 -0
  73. package/dist/{web.cjs → web/index.cjs} +4 -3
  74. package/dist/web/index.cjs.map +1 -0
  75. package/dist/{web.d.cts → web/index.d.cts} +2 -2
  76. package/dist/{web.d.ts → web/index.d.ts} +2 -2
  77. package/dist/{web.js → web/index.js} +3 -2
  78. package/dist/web/index.js.map +1 -0
  79. package/package.json +40 -12
  80. package/dist/multi-storage-BUZaLAPO.js.map +0 -1
  81. package/dist/multi-storage-BUoEoW8f.d.cts +0 -154
  82. package/dist/multi-storage-Bf84xiO8.cjs.map +0 -1
  83. package/dist/multi-storage-CQSlI_kn.d.ts +0 -154
  84. package/dist/node.cjs.map +0 -1
  85. package/dist/node.js.map +0 -1
  86. package/dist/storage-DnzZPS_9.cjs.map +0 -1
  87. package/dist/storage-IHdXw52v.js.map +0 -1
  88. package/dist/web.cjs.map +0 -1
  89. package/dist/web.js.map +0 -1
@@ -0,0 +1,3673 @@
1
+ import { O as isItdRateLimitError, _ as ItdStateError, a as ItdConfigError, b as isItdApiError, d as ItdForbiddenError, f as ItdNetworkError, g as ItdServerError, h as ItdRateLimitError, i as ItdAuthError, l as ItdFileError, m as ItdPhoneVerificationError, n as ItdApiError, o as ItdConflictError, p as ItdNotFoundError, s as ItdError, t as ItdAbortError, v as ItdTimeoutError, y as ItdValidationError } from "./errors-Bhrd2fJd.js";
2
+ import { a as buildQuery, c as isSameSite, d as originOf, l as joinUrl, n as CookieJar, s as hostOf, u as normalizeBaseUrl } from "./cookies-tX2sNwxb.js";
3
+ //#region src/core/async-dispose.ts
4
+ /**
5
+ * Подставляет метод `await using` там, где `Symbol.asyncDispose` отсутствует.
6
+ *
7
+ * В Node 18 символа нет, поэтому объявление метода `[Symbol.asyncDispose]()` кладёт его
8
+ * под ключ `"undefined"`. Здесь он переносится на ключ, который среда ищет фактически, —
9
+ * `Symbol.for('Symbol.asyncDispose')`.
10
+ *
11
+ * Вызывается из статического блока класса: так поведение привязано к самому классу и не
12
+ * зависит от того, дошёл ли bundler до модуля с побочным эффектом.
13
+ */
14
+ function installAsyncDisposeFallback(target) {
15
+ if (typeof Symbol.asyncDispose === "symbol") return;
16
+ const prototype = target.prototype;
17
+ prototype[Symbol.for("Symbol.asyncDispose")] = prototype.undefined;
18
+ delete prototype.undefined;
19
+ }
20
+ //#endregion
21
+ //#region src/core/clock.ts
22
+ /** Системные часы, используемые клиентом по умолчанию. */
23
+ const systemClock = Object.freeze({
24
+ now: () => Date.now(),
25
+ schedule(callback, delay) {
26
+ const timer = setTimeout(callback, delay);
27
+ return () => clearTimeout(timer);
28
+ }
29
+ });
30
+ /**
31
+ * Заводит срок, общий на все ожидания: от ожидания к ожиданию он не продлевается.
32
+ *
33
+ * @param timeout срок в миллисекундах; `0` — ждать без ограничения
34
+ * @internal
35
+ */
36
+ function createDeadline(timeout, clock = systemClock) {
37
+ if (timeout <= 0) return {
38
+ wait: async (promise) => {
39
+ await promise;
40
+ return true;
41
+ },
42
+ cancel: () => {}
43
+ };
44
+ let expire;
45
+ const expired = new Promise((resolve) => {
46
+ expire = resolve;
47
+ });
48
+ return {
49
+ wait: (promise) => Promise.race([promise.then(() => true), expired.then(() => false)]),
50
+ cancel: clock.schedule(() => expire(), timeout)
51
+ };
52
+ }
53
+ //#endregion
54
+ //#region src/core/runtime.ts
55
+ /**
56
+ * Как библиотека обращается с cookie.
57
+ *
58
+ * - `browser` — cookie ведёт браузер, запросы уходят с `credentials: 'include'`;
59
+ * - `server` — cookie ведёт встроенный jar, заголовок `Cookie` подставляется вручную;
60
+ * - `auto` — определяется по среде исполнения (значение по умолчанию).
61
+ */
62
+ const RuntimeMode = Object.freeze({
63
+ /** Определяется по среде исполнения. Значение по умолчанию. */
64
+ Auto: "auto",
65
+ /** Cookie ведёт браузер, запросы уходят с `credentials: 'include'`. */
66
+ Browser: "browser",
67
+ /** Cookie ведёт встроенный jar, заголовок `Cookie` подставляется вручную. */
68
+ Server: "server"
69
+ });
70
+ /** Распознанная среда исполнения. */
71
+ const DetectedRuntime = Object.freeze({
72
+ Browser: "browser",
73
+ /** Есть `window`, но нет `document`; cookie ведёт нативный сетевой слой. */
74
+ ReactNative: "react-native",
75
+ Server: "server"
76
+ });
77
+ /**
78
+ * Определяет среду исполнения.
79
+ *
80
+ * React Native выделен отдельно намеренно: там есть `window`, но нет `document`, а cookie
81
+ * хранит нативный сетевой слой (`NSHTTPCookieStorage` / `OkHttp`). Свой jar в этой среде
82
+ * привёл бы к отправке cookie дважды.
83
+ */
84
+ function detectRuntime() {
85
+ if (globalThis.navigator?.product === "ReactNative") return DetectedRuntime.ReactNative;
86
+ if (typeof document !== "undefined") return DetectedRuntime.Browser;
87
+ return DetectedRuntime.Server;
88
+ }
89
+ /**
90
+ * Нужно ли вести собственный cookie-jar.
91
+ *
92
+ * Только в серверных средах: в браузере `Set-Cookie` из JS не читается и не нужен,
93
+ * в React Native cookie ведёт нативный слой.
94
+ */
95
+ function shouldUseCookieJar(mode) {
96
+ if (mode === RuntimeMode.Browser) return false;
97
+ if (mode === RuntimeMode.Server) return true;
98
+ return detectRuntime() === DetectedRuntime.Server;
99
+ }
100
+ /**
101
+ * Нужно ли отправлять запросы с `credentials: 'include'`.
102
+ *
103
+ * Только в браузере: именно так refresh-cookie попадает в запрос `POST /api/v1/auth/refresh`.
104
+ * Требует настроенного CORS на стороне итд.com — если его нет, укажите свой прокси в `baseUrl`.
105
+ */
106
+ function shouldSendCredentials(mode) {
107
+ if (mode === RuntimeMode.Browser) return true;
108
+ if (mode === RuntimeMode.Server) return false;
109
+ return detectRuntime() === DetectedRuntime.Browser;
110
+ }
111
+ /**
112
+ * Возвращает реализацию `fetch`.
113
+ *
114
+ * @param custom реализация из конфигурации клиента, если пользователь её передал
115
+ * @throws {ItdConfigError} если `fetch` недоступен — например, на Node ниже 18
116
+ */
117
+ function resolveFetch(custom) {
118
+ if (custom !== void 0) {
119
+ if (typeof custom !== "function") throw new ItdConfigError("fetch должен быть функцией");
120
+ return custom;
121
+ }
122
+ if (typeof globalThis.fetch === "function") return globalThis.fetch.bind(globalThis);
123
+ throw new ItdConfigError("В этой среде нет глобального fetch. Обновитесь до Node 18+ либо передайте свою реализацию через опцию fetch.");
124
+ }
125
+ /**
126
+ * Проверки бинарных типов, безопасные в любой среде.
127
+ *
128
+ * Обычный `instanceof` здесь недостаточен: конструктора может не быть вовсе, и тогда
129
+ * проверка падает с `ReferenceError`, а не возвращает `false`. `Blob` есть в Node с 18.0,
130
+ * но `File` стал глобальным только в Node 20 — при заявленной поддержке Node 18 голый
131
+ * `instanceof File` ронял бы любую загрузку.
132
+ */
133
+ function isBlob(value) {
134
+ return typeof Blob !== "undefined" && value instanceof Blob;
135
+ }
136
+ /** @see {@link isBlob} */
137
+ function isFile(value) {
138
+ return typeof File !== "undefined" && value instanceof File;
139
+ }
140
+ /** Доступно ли потоковое чтение тела ответа — от этого зависит, сработает ли SSE. */
141
+ function supportsStreamingBody() {
142
+ return typeof ReadableStream !== "undefined" && typeof TextDecoder !== "undefined";
143
+ }
144
+ /**
145
+ * Создаёт идентификатор устройства для заголовка `X-Device-Id` — UUID v4.
146
+ *
147
+ * `crypto.randomUUID` есть в Node 19+, браузерах и Deno, но отсутствует в Node 18 вне
148
+ * защищённого контекста, поэтому предусмотрен запасной путь.
149
+ */
150
+ function createDeviceId() {
151
+ const webCrypto = globalThis.crypto;
152
+ if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
153
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
154
+ if (typeof webCrypto?.getRandomValues === "function") webCrypto.getRandomValues(bytes);
155
+ else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
156
+ bytes[6] = bytes[6] & 15 | 64;
157
+ bytes[8] = bytes[8] & 63 | 128;
158
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
159
+ return [
160
+ hex.slice(0, 8),
161
+ hex.slice(8, 12),
162
+ hex.slice(12, 16),
163
+ hex.slice(16, 20),
164
+ hex.slice(20, 32)
165
+ ].join("-");
166
+ }
167
+ //#endregion
168
+ //#region src/core/scheduling/pacing.ts
169
+ /** Реакция на остаток лимита из заголовков ответа. */
170
+ const RateLimitPacing = Object.freeze({
171
+ /** Задержек нет, пока в бакете есть остаток; исчерпанный бакет ждёт `60000 / limit`. */
172
+ React: "react",
173
+ /** Ровный темп в пределах минутного лимита: задержки идут с первого запроса. */
174
+ Smooth: "smooth",
175
+ /** Остаток на темп не влияет; остаётся пауза после `429`. */
176
+ Off: "off"
177
+ });
178
+ //#endregion
179
+ //#region src/core/validate.ts
180
+ /**
181
+ * Проверки, общие для резолверов конфигурации.
182
+ *
183
+ * Настройки исполнения и настройки сессии разбираются в разных модулях, но сообщать
184
+ * об ошибке должны одинаково: пользователю всё равно, какой слой отверг его значение.
185
+ */
186
+ /** Похоже ли значение на объект настроек, а не на массив или `null`. */
187
+ function isRecord$1(value) {
188
+ return typeof value === "object" && value !== null && !Array.isArray(value);
189
+ }
190
+ /** @throws {ItdConfigError} если значение задано и не является неотрицательным числом */
191
+ function requirePositive(value, name) {
192
+ if (!Number.isFinite(value) || value < 0) throw new ItdConfigError(`${name} должен быть неотрицательным числом, получено: ${value}`);
193
+ return value;
194
+ }
195
+ /** @throws {ItdConfigError} если значение задано и не является boolean */
196
+ function requireOptionalBoolean(value, name) {
197
+ if (value !== void 0 && typeof value !== "boolean") throw new ItdConfigError(`${name} должен быть boolean`);
198
+ }
199
+ //#endregion
200
+ //#region src/core/version.ts
201
+ /** Версия библиотеки. Попадает в `User-Agent`. */
202
+ const LIBRARY_VERSION = "0.7.1";
203
+ //#endregion
204
+ //#region src/core/config.ts
205
+ /** Базовый URL API итд.com. Домен записан в punycode: `итд.com`. */
206
+ const DEFAULT_BASE_URL = "https://xn--d1ah4a.com";
207
+ /** Базовый URL страницы статуса. Домен записан в punycode: `статус.итд.com`. */
208
+ const DEFAULT_STATUS_BASE_URL = "https://xn--80a7abcbg.xn--d1ah4a.com";
209
+ /** Имя встроенного сервиса статуса. */
210
+ const STATUS_SERVICE = "status";
211
+ /** Сервисы, зарегистрированные у любого клиента. */
212
+ const BUILT_IN_SERVICES = Object.freeze([Object.freeze({
213
+ name: STATUS_SERVICE,
214
+ baseUrl: DEFAULT_STATUS_BASE_URL,
215
+ auth: false
216
+ })]);
217
+ /**
218
+ * `User-Agent` по умолчанию.
219
+ *
220
+ * Сайт стоит за DDoS-Guard, и запросы вовсе без `User-Agent` (так делает `fetch` в Node)
221
+ * имеют шанс не пройти фильтр. Префикс `Mozilla/5.0` — дань традиции таких фильтров,
222
+ * дальше идёт честное имя библиотеки: подделываться под браузер она не должна.
223
+ *
224
+ * В браузере заголовок не выставляется — `User-Agent` там запрещён к изменению, и среда
225
+ * молча его игнорирует.
226
+ */
227
+ const DEFAULT_USER_AGENT = `Mozilla/5.0 (compatible; itd-api/${LIBRARY_VERSION}; +https://github.com/KiowDev/itd-api)`;
228
+ /**
229
+ * Паузы перед повторами при ответе `429`.
230
+ *
231
+ * Сервер итд.com не присылает `Retry-After` и не сообщает время сброса окна, поэтому паузу
232
+ * приходится подбирать лестницей: от секунды, если окно почти истекло, до полутора минут.
233
+ */
234
+ const DEFAULT_RATE_LIMIT_DELAYS = Object.freeze([
235
+ 1e3,
236
+ 5e3,
237
+ 3e4,
238
+ 6e4,
239
+ 9e4
240
+ ]);
241
+ function resolveHeaders(headers) {
242
+ if (headers === void 0) return {};
243
+ if (!isRecord$1(headers)) throw new ItdConfigError("headers должен быть объектом строк");
244
+ for (const [name, value] of Object.entries(headers)) if (typeof value !== "string") throw new ItdConfigError(`headers.${name} должен быть строкой`);
245
+ return { ...headers };
246
+ }
247
+ function resolveHooks(hooks) {
248
+ if (hooks === void 0) return {};
249
+ if (!isRecord$1(hooks)) throw new ItdConfigError("hooks должен быть объектом");
250
+ for (const name of [
251
+ "onRequest",
252
+ "onResponse",
253
+ "onError",
254
+ "onRetry"
255
+ ]) if (hooks[name] !== void 0 && typeof hooks[name] !== "function") throw new ItdConfigError(`hooks.${name} должен быть функцией`);
256
+ return { ...hooks };
257
+ }
258
+ function resolveLogger(logger) {
259
+ if (logger === void 0 || logger === false) return void 0;
260
+ if (logger === true) return consoleLogger();
261
+ if (!isRecord$1(logger)) throw new ItdConfigError("logger должен быть boolean или объектом Logger");
262
+ for (const method of [
263
+ "debug",
264
+ "info",
265
+ "warn",
266
+ "error"
267
+ ]) if (typeof logger[method] !== "function") throw new ItdConfigError(`logger.${method} должен быть функцией`);
268
+ return logger;
269
+ }
270
+ /** Логгер поверх `console` — включается опцией `logger: true`. */
271
+ function consoleLogger() {
272
+ return {
273
+ debug: (message, ...args) => console.debug(`[itd-api] ${message}`, ...args),
274
+ info: (message, ...args) => console.info(`[itd-api] ${message}`, ...args),
275
+ warn: (message, ...args) => console.warn(`[itd-api] ${message}`, ...args),
276
+ error: (message, ...args) => console.error(`[itd-api] ${message}`, ...args)
277
+ };
278
+ }
279
+ /**
280
+ * Приводит настройки повторов к полному виду.
281
+ *
282
+ * Вызывается и при создании клиента (для глобальной настройки), и на каждый запрос,
283
+ * у которого задан свой `retry`. Возвращает `undefined`, когда повторять не нужно
284
+ * (`retry: false` или единственная попытка).
285
+ *
286
+ * @throws {ItdConfigError} при некорректных значениях
287
+ */
288
+ function resolveRetry(retry) {
289
+ if (retry === false) return void 0;
290
+ if (retry !== void 0 && !isRecord$1(retry)) throw new ItdConfigError("retry должен быть объектом или false");
291
+ const options = retry ?? {};
292
+ const attempts = options.attempts ?? 3;
293
+ if (!Number.isInteger(attempts) || attempts < 1) throw new ItdConfigError(`retry.attempts должен быть целым числом от 1, получено: ${attempts}`);
294
+ const jitter = options.jitter ?? .3;
295
+ if (!Number.isFinite(jitter) || jitter < 0 || jitter > 1) throw new ItdConfigError(`retry.jitter должен быть в диапазоне 0…1, получено: ${jitter}`);
296
+ if (options.shouldRetry !== void 0 && typeof options.shouldRetry !== "function") throw new ItdConfigError("retry.shouldRetry должен быть функцией");
297
+ const baseDelay = requirePositive(options.baseDelay ?? 500, "retry.baseDelay");
298
+ const maxDelay = requirePositive(options.maxDelay ?? 3e4, "retry.maxDelay");
299
+ if (attempts === 1) return void 0;
300
+ return {
301
+ attempts,
302
+ baseDelay,
303
+ maxDelay,
304
+ jitter,
305
+ shouldRetry: options.shouldRetry
306
+ };
307
+ }
308
+ /**
309
+ * Сверяет имя бакета со встроенной картой.
310
+ *
311
+ * Своё правило выбора заводит собственное пространство имён, и встроенные имена в нём
312
+ * необязательны — тогда проверка снимается.
313
+ *
314
+ * @param option путь опции для текста ошибки
315
+ * @throws {ItdConfigError} если имени нет во встроенной карте
316
+ *
317
+ * @internal
318
+ */
319
+ function assertKnownBucket(name, option, bucket, catalog) {
320
+ if (bucket !== void 0 || catalog.isKnownBucket(name)) return;
321
+ throw new ItdConfigError(`${option}: бакета «${name}» нет. Известны: ${Object.keys(catalog.bucketLimits).join(", ")}`);
322
+ }
323
+ function requireConcurrency(value, name) {
324
+ if (!Number.isInteger(value) || value < 1) throw new ItdConfigError(`${name} должен быть целым числом от 1, получено: ${value}`);
325
+ return value;
326
+ }
327
+ function resolvePacing(pacing) {
328
+ const known = Object.values(RateLimitPacing);
329
+ if (pacing !== void 0 && !known.includes(pacing)) throw new ItdConfigError(`rateLimit.pacing должен быть одним из ${known.join(", ")}, получено: ${pacing}`);
330
+ return pacing ?? RateLimitPacing.React;
331
+ }
332
+ /**
333
+ * Проверяет поправки бакетов и накладывает их на встроенные.
334
+ *
335
+ * Имена сверяются со встроенной картой, пока не задано своё правило выбора бакета.
336
+ * Поля сливаются по отдельности: своя ёмкость `files.upload` не снимает встроенный
337
+ * предел одновременности.
338
+ */
339
+ function resolveBucketOverrides(overrides, bucket, catalog) {
340
+ const builtIn = catalog.bucketOverrides;
341
+ if (overrides === void 0) return builtIn;
342
+ if (!isRecord$1(overrides)) throw new ItdConfigError("rateLimit.bucketOverrides должен быть объектом");
343
+ const resolved = { ...builtIn };
344
+ for (const [name, override] of Object.entries(overrides)) {
345
+ if (!isRecord$1(override)) throw new ItdConfigError(`rateLimit.bucketOverrides.${name} должен быть объектом`);
346
+ assertKnownBucket(name, "rateLimit.bucketOverrides", bucket, catalog);
347
+ if (override.concurrency !== void 0) requireConcurrency(override.concurrency, `rateLimit.bucketOverrides.${name}.concurrency`);
348
+ if (override.limit !== void 0 && (!Number.isFinite(override.limit) || override.limit <= 0)) throw new ItdConfigError(`rateLimit.bucketOverrides.${name}.limit должен быть положительным числом, получено: ${override.limit}`);
349
+ const built = builtIn[name];
350
+ resolved[name] = Object.freeze({
351
+ concurrency: override.concurrency ?? built?.concurrency,
352
+ limit: override.limit ?? built?.limit
353
+ });
354
+ }
355
+ return Object.freeze(resolved);
356
+ }
357
+ /**
358
+ * Приводит настройки очереди к полному виду. `undefined` — очередь не нужна.
359
+ *
360
+ * Кроме создания клиента вызывается ещё из {@link ItdAccounts}: общая на всех аккаунтов
361
+ * очередь заводится из тех же опций и с теми же проверками.
362
+ *
363
+ * @throws {ItdConfigError} при некорректных значениях
364
+ *
365
+ * @internal
366
+ */
367
+ function resolveRateLimit(rateLimit, catalog) {
368
+ if (rateLimit === false) return void 0;
369
+ if (rateLimit !== void 0 && !isRecord$1(rateLimit)) throw new ItdConfigError("rateLimit должен быть объектом или false");
370
+ const defaults = {
371
+ concurrency: 6,
372
+ rps: void 0,
373
+ retryDelays: DEFAULT_RATE_LIMIT_DELAYS,
374
+ buckets: true,
375
+ pacing: RateLimitPacing.React,
376
+ bucketConcurrency: 6,
377
+ bucketOverrides: catalog.bucketOverrides,
378
+ bucket: void 0,
379
+ bucketLimits: catalog.bucketLimits,
380
+ defaultBucket: catalog.defaultBucket
381
+ };
382
+ if (!rateLimit) return defaults;
383
+ const concurrency = requireConcurrency(rateLimit.concurrency ?? 6, "rateLimit.concurrency");
384
+ if (rateLimit.rps !== void 0 && (!Number.isFinite(rateLimit.rps) || rateLimit.rps <= 0)) throw new ItdConfigError(`rateLimit.rps должен быть положительным числом, получено: ${rateLimit.rps}`);
385
+ const retryDelays = rateLimit.retryDelays ?? defaults.retryDelays;
386
+ if (!Array.isArray(retryDelays)) throw new ItdConfigError("rateLimit.retryDelays должен быть массивом чисел");
387
+ for (const delay of retryDelays) requirePositive(delay, "rateLimit.retryDelays");
388
+ requireOptionalBoolean(rateLimit.buckets, "rateLimit.buckets");
389
+ if (rateLimit.bucket !== void 0 && typeof rateLimit.bucket !== "function") throw new ItdConfigError("rateLimit.bucket должен быть функцией");
390
+ return {
391
+ concurrency,
392
+ rps: rateLimit.rps,
393
+ retryDelays: [...retryDelays],
394
+ buckets: rateLimit.buckets ?? true,
395
+ pacing: resolvePacing(rateLimit.pacing),
396
+ bucketConcurrency: requireConcurrency(rateLimit.bucketConcurrency ?? concurrency, "rateLimit.bucketConcurrency"),
397
+ bucketOverrides: resolveBucketOverrides(rateLimit.bucketOverrides, rateLimit.bucket, catalog),
398
+ bucket: rateLimit.bucket,
399
+ bucketLimits: catalog.bucketLimits,
400
+ defaultBucket: catalog.defaultBucket
401
+ };
402
+ }
403
+ /**
404
+ * Разворачивает запись сервисов из опций в определения: имя берётся из ключа, строка
405
+ * означает один только базовый URL. URL проверяется при регистрации сервиса.
406
+ */
407
+ function resolveServices(services) {
408
+ if (services === void 0) return [];
409
+ if (!isRecord$1(services)) throw new ItdConfigError("services должен быть объектом");
410
+ return Object.entries(services).map(([name, value]) => {
411
+ if (typeof value === "string") return {
412
+ name,
413
+ baseUrl: value
414
+ };
415
+ if (!isRecord$1(value)) throw new ItdConfigError(`services.${name} должен быть URL или объектом сервиса`);
416
+ return {
417
+ ...value,
418
+ name
419
+ };
420
+ });
421
+ }
422
+ /**
423
+ * Приводит настройки исполнения запросов к полному виду.
424
+ *
425
+ * Все проверки выполняются здесь, до единого сетевого запроса: неверная настройка должна
426
+ * проявляться при создании клиента, а не через полчаса работы бота. Сессионные опции
427
+ * разбирает `resolveSessionConfig` — ядру они не нужны.
428
+ *
429
+ * @throws {ItdConfigError} при некорректных значениях
430
+ */
431
+ function resolveRuntimeConfig(options, catalog) {
432
+ if (!isRecord$1(options)) throw new ItdConfigError("опции клиента должны быть объектом");
433
+ const mode = options.mode ?? RuntimeMode.Auto;
434
+ if (!Object.values(RuntimeMode).includes(mode)) throw new ItdConfigError(`mode должен быть одним из ${Object.values(RuntimeMode).join(", ")}, получено: ${mode}`);
435
+ const timeout = requirePositive(options.timeout ?? 3e4, "timeout");
436
+ const shutdownTimeout = requirePositive(options.shutdownTimeout ?? 1e4, "shutdownTimeout");
437
+ if (options.clock !== void 0 && (typeof options.clock !== "object" || options.clock === null || typeof options.clock.now !== "function" || typeof options.clock.schedule !== "function")) throw new ItdConfigError("clock должен предоставлять методы now() и schedule()");
438
+ if (options.userAgent !== void 0 && options.userAgent !== false && typeof options.userAgent !== "string") throw new ItdConfigError("userAgent должен быть строкой или false");
439
+ return {
440
+ baseUrl: normalizeBaseUrl(options.baseUrl ?? "https://xn--d1ah4a.com"),
441
+ services: resolveServices(options.services),
442
+ fetch: resolveFetch(options.fetch),
443
+ clock: options.clock ?? systemClock,
444
+ timeout,
445
+ shutdownTimeout,
446
+ retry: resolveRetry(options.retry),
447
+ rateLimit: resolveRateLimit(options.rateLimit, catalog),
448
+ hooks: resolveHooks(options.hooks),
449
+ headers: resolveHeaders(options.headers),
450
+ userAgent: options.userAgent === false ? void 0 : options.userAgent ?? DEFAULT_USER_AGENT,
451
+ mode,
452
+ useCookieJar: shouldUseCookieJar(mode),
453
+ sendCredentials: shouldSendCredentials(mode),
454
+ logger: resolveLogger(options.logger)
455
+ };
456
+ }
457
+ //#endregion
458
+ //#region src/core/plugins/attempts.ts
459
+ const ATTEMPT_SCOPE = Symbol("itd-api.attempt-interceptors");
460
+ /** Структурная проверка сохраняет совместимость с fetch-polyfill из другого realm. */
461
+ function isResponse(value) {
462
+ if (typeof value !== "object" || value === null) return false;
463
+ const candidate = value;
464
+ return typeof candidate.status === "number" && typeof candidate.clone === "function" && typeof candidate.headers?.get === "function";
465
+ }
466
+ /** Привязывает к логической операции неизменяемый снимок attempt interceptors. @internal */
467
+ function withAttemptInterceptorScope(request, interceptors) {
468
+ return request[ATTEMPT_SCOPE] === interceptors ? request : {
469
+ ...request,
470
+ [ATTEMPT_SCOPE]: interceptors
471
+ };
472
+ }
473
+ /** Читает снимок attempt interceptors логической операции. @internal */
474
+ function attemptInterceptorScope(request) {
475
+ return request[ATTEMPT_SCOPE] ?? [];
476
+ }
477
+ /**
478
+ * Выполняет обёртки одной попытки.
479
+ *
480
+ * Каждый `next` одноразовый: interceptor может short-circuit попытку синтетическим
481
+ * `Response`, но не может незаметно породить два сетевых запроса внутри одного attempt.
482
+ */
483
+ async function runAttemptInterceptors(request, context, execute) {
484
+ return attemptInterceptorScope(request).reduceRight((next, { plugin, interceptor }) => async () => {
485
+ let called = false;
486
+ const response = await interceptor(context, () => {
487
+ if (called) throw new ItdConfigError(`attempt interceptor плагина «${plugin}» вызвал next() больше одного раза`);
488
+ called = true;
489
+ return next();
490
+ });
491
+ if (!isResponse(response)) throw new ItdConfigError(`attempt interceptor плагина «${plugin}» должен вернуть Response`);
492
+ return response;
493
+ }, execute)();
494
+ }
495
+ //#endregion
496
+ //#region src/core/plugins/order.ts
497
+ const RELATION_FIELDS = [
498
+ "requires",
499
+ "conflicts",
500
+ "before",
501
+ "after"
502
+ ];
503
+ function validateNameList(plugin, field) {
504
+ const values = plugin[field];
505
+ if (values === void 0) return;
506
+ if (!Array.isArray(values)) throw new ItdConfigError(`плагин «${plugin.name}»: ${field} должен быть массивом`);
507
+ const seen = /* @__PURE__ */ new Set();
508
+ for (const value of values) {
509
+ if (typeof value !== "string" || value.trim() === "") throw new ItdConfigError(`плагин «${plugin.name}»: ${field} содержит пустое имя плагина`);
510
+ if (value === plugin.name) throw new ItdConfigError(`плагин «${plugin.name}» не может указать себя в ${field}`);
511
+ if (seen.has(value)) throw new ItdConfigError(`плагин «${plugin.name}»: ${field} повторяет имя «${value}»`);
512
+ seen.add(value);
513
+ }
514
+ }
515
+ /**
516
+ * Проверяет описание плагина без его установки.
517
+ *
518
+ * Нужна не только {@link PluginRegistry}: контейнер аккаунтов обязан отклонять сломанный
519
+ * плагин сразу, даже когда внутри ещё нет ни одного клиента, которому можно поручить
520
+ * полноценную установку.
521
+ *
522
+ * @internal
523
+ */
524
+ function validatePluginDefinition(plugin) {
525
+ if (typeof plugin?.install !== "function") throw new ItdConfigError("Плагин должен быть объектом с методом install()");
526
+ const name = plugin.name;
527
+ if (typeof name !== "string" || name.trim() === "") throw new ItdConfigError("У плагина должно быть непустое имя");
528
+ for (const field of RELATION_FIELDS) validateNameList(plugin, field);
529
+ }
530
+ function addEdge(from, to, edges, indegree) {
531
+ const targets = edges.get(from);
532
+ if (!targets || targets.has(to)) return;
533
+ targets.add(to);
534
+ indegree.set(to, (indegree.get(to) ?? 0) + 1);
535
+ }
536
+ /**
537
+ * Проверяет зависимости и возвращает плагины в порядке обёрток: внешний идёт раньше.
538
+ *
539
+ * @internal
540
+ */
541
+ function orderPluginDefinitions(plugins) {
542
+ const entries = [];
543
+ const byName = /* @__PURE__ */ new Map();
544
+ for (const [sequence, plugin] of plugins.entries()) {
545
+ validatePluginDefinition(plugin);
546
+ if (byName.has(plugin.name)) throw new ItdConfigError(`плагин «${plugin.name}» уже подключён`);
547
+ const entry = {
548
+ plugin,
549
+ sequence
550
+ };
551
+ entries.push(entry);
552
+ byName.set(plugin.name, entry);
553
+ }
554
+ for (const { plugin } of entries) {
555
+ for (const required of plugin.requires ?? []) if (!byName.has(required)) throw new ItdConfigError(`плагину «${plugin.name}» требуется плагин «${required}»`);
556
+ for (const conflict of plugin.conflicts ?? []) if (byName.has(conflict)) throw new ItdConfigError(`плагин «${plugin.name}» несовместим с плагином «${conflict}»`);
557
+ for (const { plugin: other } of entries) if (other.conflicts?.includes(plugin.name)) throw new ItdConfigError(`плагин «${plugin.name}» несовместим с плагином «${other.name}»`);
558
+ }
559
+ const edges = /* @__PURE__ */ new Map();
560
+ const indegree = /* @__PURE__ */ new Map();
561
+ for (const { plugin } of entries) {
562
+ edges.set(plugin.name, /* @__PURE__ */ new Set());
563
+ indegree.set(plugin.name, 0);
564
+ }
565
+ for (const { plugin } of entries) {
566
+ for (const required of plugin.requires ?? []) addEdge(required, plugin.name, edges, indegree);
567
+ for (const target of plugin.before ?? []) if (byName.has(target)) addEdge(plugin.name, target, edges, indegree);
568
+ for (const target of plugin.after ?? []) if (byName.has(target)) addEdge(target, plugin.name, edges, indegree);
569
+ }
570
+ const ready = entries.filter(({ plugin }) => indegree.get(plugin.name) === 0);
571
+ ready.sort((a, b) => a.sequence - b.sequence);
572
+ const ordered = [];
573
+ while (ready.length > 0) {
574
+ const current = ready.shift();
575
+ if (!current) break;
576
+ ordered.push(current.plugin);
577
+ for (const target of edges.get(current.plugin.name) ?? []) {
578
+ const next = (indegree.get(target) ?? 0) - 1;
579
+ indegree.set(target, next);
580
+ if (next === 0) {
581
+ const entry = byName.get(target);
582
+ if (entry) {
583
+ ready.push(entry);
584
+ ready.sort((a, b) => a.sequence - b.sequence);
585
+ }
586
+ }
587
+ }
588
+ }
589
+ if (ordered.length !== entries.length) {
590
+ const cycle = entries.filter(({ plugin }) => (indegree.get(plugin.name) ?? 0) > 0).map(({ plugin }) => plugin.name);
591
+ throw new ItdConfigError(`циклический порядок плагинов: ${cycle.join(" → ")}`);
592
+ }
593
+ return ordered;
594
+ }
595
+ /** Проверяет, можно ли удалить плагин без нарушения обязательных зависимостей. @internal */
596
+ function assertPluginRemovable(plugins, name) {
597
+ const dependent = plugins.find((plugin) => plugin.requires?.includes(name));
598
+ if (dependent) throw new ItdConfigError(`нельзя отключить плагин «${name}»: от него зависит «${dependent.name}»`);
599
+ }
600
+ //#endregion
601
+ //#region src/core/plugins/registry.ts
602
+ /**
603
+ * Список подключённых плагинов и зарегистрированных ими расширений.
604
+ *
605
+ * {@link run} собирает operation transformers вокруг одной логической операции и прикрепляет
606
+ * к ней неизменяемый снимок attempt interceptors. Transport исполняет этот снимок отдельно
607
+ * для каждой сетевой попытки. Порядок плагинов одинаков для обеих цепочек.
608
+ *
609
+ * @internal
610
+ */
611
+ var PluginRegistry = class {
612
+ #entries = /* @__PURE__ */ new Map();
613
+ #removing = /* @__PURE__ */ new Set();
614
+ #cleanups = /* @__PURE__ */ new Set();
615
+ #options;
616
+ #ordered = [];
617
+ constructor(options) {
618
+ this.#options = options;
619
+ }
620
+ /** Сколько плагинов подключено. */
621
+ get size() {
622
+ return this.#entries.size;
623
+ }
624
+ /** Имена плагинов в фактическом порядке выполнения. */
625
+ names() {
626
+ return this.#ordered.map(({ plugin }) => plugin.name);
627
+ }
628
+ /** Подключён ли плагин с таким именем. */
629
+ has(name) {
630
+ return this.#entries.has(name);
631
+ }
632
+ /** Проверяет добавление без вызова `install()`. @internal */
633
+ assertCanAdd(plugin) {
634
+ validatePluginDefinition(plugin);
635
+ if (this.#removing.has(plugin.name)) throw new ItdConfigError(`плагин «${plugin.name}» ещё отключается; дождитесь завершения unuse() или dispose()`);
636
+ orderPluginDefinitions([...this.#ordered.map((entry) => entry.plugin), plugin]);
637
+ }
638
+ /** Проверяет удаление без изменения реестра. @internal */
639
+ assertCanRemove(name) {
640
+ if (!this.#entries.get(name)) return;
641
+ assertPluginRemovable(this.#ordered.map((current) => current.plugin), name);
642
+ }
643
+ /**
644
+ * Подключает плагин.
645
+ *
646
+ * `install()` выполняется синхронно. Каждая регистрация принадлежит установившему её
647
+ * плагину и участвует в общем порядке `before`/`after`.
648
+ *
649
+ * @throws {ItdConfigError} если плагин задан неверно, уже подключён или нарушает зависимости
650
+ */
651
+ add(plugin, context) {
652
+ this.assertCanAdd(plugin);
653
+ const ordered = orderPluginDefinitions([...this.#ordered.map((entry) => entry.plugin), plugin]);
654
+ const transformers = [];
655
+ const interceptors = [];
656
+ const register = (values, value, kind) => {
657
+ if (typeof value !== "function") throw new ItdConfigError(`плагин «${plugin.name}» передал в ${kind}.use() не функцию`);
658
+ values.push(value);
659
+ let active = true;
660
+ return () => {
661
+ if (!active) return;
662
+ active = false;
663
+ const index = values.indexOf(value);
664
+ if (index >= 0) values.splice(index, 1);
665
+ };
666
+ };
667
+ const installed = plugin.install({
668
+ ...context,
669
+ operations: { use: (transformer) => register(transformers, transformer, "operations") },
670
+ attempts: { use: (interceptor) => register(interceptors, interceptor, "attempts") }
671
+ });
672
+ if (installed !== void 0 && typeof installed !== "function") throw new ItdConfigError(`install() плагина «${plugin.name}» должен вернуть функцию или void`);
673
+ const teardown = installed;
674
+ this.#entries.set(plugin.name, {
675
+ plugin,
676
+ transformers,
677
+ interceptors,
678
+ teardown,
679
+ activeRequests: 0,
680
+ drain: void 0,
681
+ finishDrain: void 0
682
+ });
683
+ this.#ordered = ordered.map((definition) => {
684
+ const entry = this.#entries.get(definition.name);
685
+ if (!entry) throw new ItdConfigError(`плагин «${definition.name}» не установлен`);
686
+ return entry;
687
+ });
688
+ }
689
+ /**
690
+ * Отключает плагин и вызывает его функцию очистки.
691
+ *
692
+ * Новые запросы перестают видеть расширения плагина сразу. Снимок уже начавшейся операции,
693
+ * включая её будущие retry, остаётся неизменным; очистка дождётся завершения операции,
694
+ * но не дольше отведённого срока.
695
+ *
696
+ * @returns `false`, если такого плагина не было
697
+ * @throws {ItdStateError} если операции плагина не завершились за отведённый срок
698
+ */
699
+ async remove(name) {
700
+ const entry = this.#entries.get(name);
701
+ if (!entry) return false;
702
+ this.assertCanRemove(name);
703
+ this.#entries.delete(name);
704
+ this.#ordered = this.#ordered.filter((current) => current !== entry);
705
+ this.#removing.add(name);
706
+ const deadline = createDeadline(this.#options.shutdownTimeout, this.#options.clock);
707
+ const cleanup = this.#trackCleanup((async () => {
708
+ const expired = await this.#release(entry, deadline);
709
+ if (expired) throw expired;
710
+ })());
711
+ try {
712
+ await cleanup;
713
+ return true;
714
+ } finally {
715
+ deadline.cancel();
716
+ this.#removing.delete(name);
717
+ }
718
+ }
719
+ /**
720
+ * Отключает все плагины окончательно.
721
+ *
722
+ * Очистка идёт изнутри наружу — в порядке, обратном выполнению расширений. Срок ожидания
723
+ * общий на все плагины.
724
+ */
725
+ async dispose() {
726
+ const entries = [...this.#ordered].reverse();
727
+ const previousCleanups = [...this.#cleanups];
728
+ this.#entries.clear();
729
+ this.#ordered = [];
730
+ for (const { plugin } of entries) this.#removing.add(plugin.name);
731
+ const deadline = createDeadline(this.#options.shutdownTimeout, this.#options.clock);
732
+ const cleanup = this.#trackCleanup((async () => {
733
+ const errors = [];
734
+ const previous = await Promise.allSettled(previousCleanups);
735
+ for (const result of previous) if (result.status === "rejected") errors.push(result.reason);
736
+ for (const entry of entries) try {
737
+ const expired = await this.#release(entry, deadline);
738
+ if (expired) errors.push(expired);
739
+ } catch (error) {
740
+ errors.push(error);
741
+ } finally {
742
+ this.#removing.delete(entry.plugin.name);
743
+ }
744
+ if (errors.length > 0) throw new AggregateError(errors, "Не удалось освободить ресурсы плагинов");
745
+ })());
746
+ try {
747
+ await cleanup;
748
+ } finally {
749
+ deadline.cancel();
750
+ }
751
+ }
752
+ /**
753
+ * Прогоняет запрос через operation transformers и прикрепляет attempt interceptors.
754
+ *
755
+ * Снимки обеих цепочек берутся в начале: `unuse()` влияет на новые операции, но не меняет
756
+ * уже выполняющуюся и не удаляет interceptors из её последующих retry.
757
+ *
758
+ * Каждый `next` одноразовый: transformer может завершить операцию сам, но не может породить
759
+ * вторую — для `posts.create` это была бы вторая публикация.
760
+ *
761
+ * @param execute выполнение логической операции, вызываемое самым внутренним transformer
762
+ */
763
+ async run(request, execute) {
764
+ const entries = [...this.#ordered];
765
+ for (const entry of entries) entry.activeRequests += 1;
766
+ const interceptorScope = entries.flatMap((entry) => entry.interceptors.map((interceptor) => ({
767
+ plugin: entry.plugin.name,
768
+ interceptor
769
+ })));
770
+ const scoped = (current) => withAttemptInterceptorScope(current, interceptorScope);
771
+ const chain = entries.flatMap((entry) => entry.transformers.map((transformer) => ({
772
+ plugin: entry.plugin.name,
773
+ transformer
774
+ }))).reduceRight((next, { plugin, transformer }) => (current) => {
775
+ let called = false;
776
+ return transformer(scoped(current), (prepared) => {
777
+ if (called) throw new ItdConfigError(`operation transformer плагина «${plugin}» вызвал next() больше одного раза`);
778
+ called = true;
779
+ return next(scoped(prepared));
780
+ });
781
+ }, (current) => execute(scoped(current)));
782
+ try {
783
+ return await chain(scoped(request));
784
+ } finally {
785
+ for (const entry of entries) {
786
+ entry.activeRequests -= 1;
787
+ if (entry.activeRequests === 0) {
788
+ entry.finishDrain?.();
789
+ entry.finishDrain = void 0;
790
+ entry.drain = void 0;
791
+ }
792
+ }
793
+ }
794
+ }
795
+ /**
796
+ * Дожидается операций плагина и освобождает его ресурсы.
797
+ *
798
+ * `teardown` выполняется в любом случае, в том числе после истечения срока.
799
+ *
800
+ * @returns ошибка истёкшего срока, если ждать пришлось дольше отведённого
801
+ */
802
+ async #release(entry, deadline) {
803
+ const finished = await deadline.wait(this.#waitForDrain(entry));
804
+ await entry.teardown?.();
805
+ return finished ? void 0 : new ItdStateError(`плагин «${entry.plugin.name}» не завершил операции за ${this.#options.shutdownTimeout} мс; ожидание прекращено, ресурсы плагина освобождены`);
806
+ }
807
+ #waitForDrain(entry) {
808
+ if (entry.activeRequests === 0) return Promise.resolve();
809
+ entry.drain ??= new Promise((resolve) => {
810
+ entry.finishDrain = resolve;
811
+ });
812
+ return entry.drain;
813
+ }
814
+ #trackCleanup(cleanup) {
815
+ this.#cleanups.add(cleanup);
816
+ cleanup.then(() => this.#cleanups.delete(cleanup), () => this.#cleanups.delete(cleanup));
817
+ return cleanup;
818
+ }
819
+ };
820
+ //#endregion
821
+ //#region src/core/scheduling/rate-limit.ts
822
+ /** Ошибка отмены запроса, который ещё не дошёл до транспорта. */
823
+ function queueAbortError() {
824
+ return new ItdAbortError("Запрос отменён во время ожидания очереди");
825
+ }
826
+ /** Ошибка запроса, которого застала остановка очереди. */
827
+ function queueStoppedError() {
828
+ return new ItdAbortError("Клиент закрыт, запрос отменён");
829
+ }
830
+ /**
831
+ * Очередь запросов: ограничивает одновременность и частоту.
832
+ *
833
+ * Нужна прежде всего ботам: без неё цикл по сотне постов уходит в API одним залпом
834
+ * и упирается в `RATE_LIMIT_EXCEEDED`.
835
+ *
836
+ * Частота выдерживается равномерным разносом стартов (`1000 / rps` между запросами),
837
+ * а не окном со счётчиком: так нагрузка ровная, без всплеска в начале каждой секунды.
838
+ *
839
+ * @internal
840
+ */
841
+ var RequestQueue = class {
842
+ #concurrency;
843
+ /** Минимальный промежуток между стартами, мс. `0` — без ограничения частоты. */
844
+ #minGap;
845
+ #onDispatch;
846
+ #waiting = [];
847
+ #active = 0;
848
+ /** Момент, раньше которого следующий запрос стартовать не должен. */
849
+ #nextSlot = 0;
850
+ #clock;
851
+ #cancelTimer;
852
+ constructor(options, clock = systemClock) {
853
+ this.#concurrency = options.concurrency;
854
+ this.#minGap = options.rps ? 1e3 / options.rps : 0;
855
+ this.#onDispatch = options.onDispatch;
856
+ this.#clock = clock;
857
+ }
858
+ /** Сколько задач выполняется прямо сейчас. */
859
+ get active() {
860
+ return this.#active;
861
+ }
862
+ /** Сколько задач ждёт очереди. */
863
+ get pending() {
864
+ return this.#waiting.length;
865
+ }
866
+ /**
867
+ * Ставит задачу в очередь.
868
+ *
869
+ * @returns результат задачи; ошибка задачи пробрасывается без изменений
870
+ */
871
+ schedule(task, signal) {
872
+ if (signal?.aborted) return Promise.reject(queueAbortError());
873
+ return new Promise((resolve, reject) => {
874
+ let queued;
875
+ const detach = () => signal?.removeEventListener("abort", onAbort);
876
+ const onAbort = () => {
877
+ const index = this.#waiting.indexOf(queued);
878
+ if (index < 0) return;
879
+ this.#waiting.splice(index, 1);
880
+ queued.cancel(queueAbortError());
881
+ this.#drain();
882
+ };
883
+ queued = {
884
+ run: () => {
885
+ detach();
886
+ this.#active += 1;
887
+ this.#onDispatch?.();
888
+ Promise.resolve().then(task).then(resolve, reject).finally(() => {
889
+ this.#active -= 1;
890
+ this.#drain();
891
+ });
892
+ },
893
+ cancel: (reason) => {
894
+ detach();
895
+ reject(reason);
896
+ }
897
+ };
898
+ this.#waiting.push(queued);
899
+ signal?.addEventListener("abort", onAbort, { once: true });
900
+ if (signal?.aborted) onAbort();
901
+ else this.#drain();
902
+ });
903
+ }
904
+ /**
905
+ * Останавливает очередь: снимает отложенную паузу и отклоняет ещё не начатые задачи
906
+ * ошибкой `ItdAbortError`. Уже выполняющиеся задачи доводятся до конца.
907
+ */
908
+ stop() {
909
+ if (this.#cancelTimer) {
910
+ this.#cancelTimer();
911
+ this.#cancelTimer = void 0;
912
+ }
913
+ const pending = this.#waiting.splice(0, this.#waiting.length);
914
+ for (const task of pending) task.cancel(queueStoppedError());
915
+ }
916
+ /** Придерживает очередь на заданное время: ждут все её задачи, а не только одна. */
917
+ pause(ms) {
918
+ if (ms <= 0) return;
919
+ this.#nextSlot = Math.max(this.#nextSlot, this.#clock.now() + ms);
920
+ }
921
+ /** Запускает столько ожидающих задач, сколько позволяют ограничения. */
922
+ #drain() {
923
+ if (this.#waiting.length === 0) {
924
+ if (this.#cancelTimer) {
925
+ this.#cancelTimer();
926
+ this.#cancelTimer = void 0;
927
+ }
928
+ return;
929
+ }
930
+ if (this.#active >= this.#concurrency) return;
931
+ if (this.#cancelTimer) return;
932
+ const now = this.#clock.now();
933
+ if (this.#nextSlot > now) {
934
+ this.#cancelTimer = this.#clock.schedule(() => {
935
+ this.#cancelTimer = void 0;
936
+ this.#drain();
937
+ }, this.#nextSlot - now);
938
+ return;
939
+ }
940
+ const next = this.#waiting.shift();
941
+ if (!next) return;
942
+ if (this.#minGap > 0) this.#nextSlot = now + this.#minGap;
943
+ next.run();
944
+ this.#drain();
945
+ }
946
+ };
947
+ /** Длина окна лимита на сервере. */
948
+ const RATE_LIMIT_WINDOW = 6e4;
949
+ /**
950
+ * Пауза при исчерпании бакета неизвестной ёмкости.
951
+ *
952
+ * Ёмкость приходит в заголовке вместе с остатком, поэтому случай возможен только у чужого
953
+ * прокси, который прислал `remaining` без `limit`.
954
+ */
955
+ const UNKNOWN_CAPACITY_PAUSE = 1e3;
956
+ /**
957
+ * Очередь одного бакета поверх общей очереди направления.
958
+ *
959
+ * Задача занимает слот бакета, затем общий слот направления, поэтому суммарная
960
+ * одновременность остаётся равной `concurrency`. Пауза бакета удерживает задачу до
961
+ * захвата общего слота: притормозивший счётчик не занимает общую ёмкость.
962
+ *
963
+ * @internal
964
+ */
965
+ var BucketQueue = class {
966
+ #destination;
967
+ #bucket;
968
+ #gate;
969
+ #shared;
970
+ #clock;
971
+ #pacing;
972
+ /** Ровный темп. Требует раздельных бакетов: без них ёмкость счётчика неизвестна. */
973
+ #smooth;
974
+ /**
975
+ * Пауза на исчерпанный остаток в режиме `buckets: false`; `undefined` — бакеты разделены.
976
+ *
977
+ * Одна очередь на направление принимает заголовки всех счётчиков вперемешку, поэтому
978
+ * `x-ratelimit-limit` принадлежит тому счётчику, который ответил последним, и ёмкость
979
+ * очереди из него не выводится: ответ `posts.create` с ёмкостью 5 остановил бы всё
980
+ * направление на двенадцать секунд. Вместо расчёта берётся первая ступень `retryDelays`.
981
+ */
982
+ #flatPause;
983
+ /** Лимит бакета до первого ответа. */
984
+ #seedLimit;
985
+ /** Последнее, что сказал сервер. Живёт и в режиме `off` — ради `rateLimitState()`. */
986
+ #limit;
987
+ #remaining;
988
+ /**
989
+ * Оценка остатка для режима `smooth`.
990
+ *
991
+ * Начинается с единицы, а не с полной ёмкости: где сейчас граница минутного окна,
992
+ * из ответа не вывести, и считать бакет нетронутым нельзя.
993
+ */
994
+ #tokens = 1;
995
+ #tokensAt;
996
+ /**
997
+ * Номер поколения очереди. `stop()` увеличивает его, отсекая задачи, которые уже взяли
998
+ * слот бакета, но до общей очереди ещё не дошли.
999
+ */
1000
+ #generation = 0;
1001
+ constructor(destination, bucket, shared, options, clock) {
1002
+ this.#destination = destination;
1003
+ this.#bucket = bucket;
1004
+ this.#shared = shared;
1005
+ this.#clock = clock;
1006
+ this.#pacing = options.pacing;
1007
+ this.#smooth = options.buckets && options.pacing === RateLimitPacing.Smooth;
1008
+ this.#flatPause = options.buckets ? void 0 : options.retryDelays[0] ?? 0;
1009
+ this.#seedLimit = options.buckets ? seedLimit(bucket, options) : void 0;
1010
+ this.#gate = new RequestQueue({
1011
+ concurrency: options.buckets ? options.bucketOverrides[bucket]?.concurrency ?? options.bucketConcurrency : options.concurrency,
1012
+ onDispatch: this.#smooth ? () => this.#spend() : void 0
1013
+ }, clock);
1014
+ }
1015
+ /** Имя счётчика. При `buckets: false` — всегда `default`, каким бы ни был запрос. */
1016
+ get bucket() {
1017
+ return this.#bucket;
1018
+ }
1019
+ /** Запросов бакета прошло в общую очередь и ещё не завершилось. */
1020
+ get active() {
1021
+ return this.#gate.active;
1022
+ }
1023
+ /** Запросов бакета ждёт своей очереди. */
1024
+ get pending() {
1025
+ return this.#gate.pending;
1026
+ }
1027
+ /** Ставит запрос в очередь: сначала слот бакета, затем общий слот направления. */
1028
+ schedule(task, signal) {
1029
+ const generation = this.#generation;
1030
+ return this.#gate.schedule(() => {
1031
+ if (generation !== this.#generation) return Promise.reject(queueStoppedError());
1032
+ return this.#shared.schedule(task, signal);
1033
+ }, signal);
1034
+ }
1035
+ /**
1036
+ * Учитывает заголовки ответа.
1037
+ *
1038
+ * Вызывается после каждого ответа, включая ошибочные: сервер списывает квоту одинаково
1039
+ * с `404`, `422` и `200`.
1040
+ *
1041
+ * @returns на сколько миллисекунд придержан бакет; `0` — темп не ограничен
1042
+ */
1043
+ observe(limit, remaining) {
1044
+ if (limit !== void 0 && Number.isFinite(limit) && limit > 0) this.#limit = limit;
1045
+ if (remaining !== void 0) this.#remaining = remaining;
1046
+ if (this.#pacing === RateLimitPacing.Off || remaining === void 0) return 0;
1047
+ if (this.#flatPause !== void 0) {
1048
+ if (remaining > 0) return 0;
1049
+ this.#gate.pause(this.#flatPause);
1050
+ return this.#flatPause;
1051
+ }
1052
+ const capacity = this.#capacity();
1053
+ if (this.#smooth) {
1054
+ if (capacity === void 0) return 0;
1055
+ this.#refill(capacity);
1056
+ if (remaining < this.#tokens) this.#tokens = remaining;
1057
+ return this.#armPause(capacity);
1058
+ }
1059
+ if (remaining > 0) return 0;
1060
+ const wait = capacity === void 0 ? UNKNOWN_CAPACITY_PAUSE : Math.ceil(RATE_LIMIT_WINDOW / Math.max(capacity, 1));
1061
+ this.#gate.pause(wait);
1062
+ return wait;
1063
+ }
1064
+ /** Придерживает бакет на названное время — путь ответа `429`. Оценка остатка обнуляется. */
1065
+ pause(ms) {
1066
+ if (this.#smooth) {
1067
+ this.#tokens = 0;
1068
+ this.#tokensAt = this.#clock.now();
1069
+ }
1070
+ this.#gate.pause(ms);
1071
+ }
1072
+ /** Снимок для `rateLimitState()`. */
1073
+ state() {
1074
+ return {
1075
+ destination: this.#destination,
1076
+ bucket: this.#bucket,
1077
+ limit: this.#limit,
1078
+ remaining: this.#remaining,
1079
+ active: this.#gate.active,
1080
+ pending: this.#gate.pending
1081
+ };
1082
+ }
1083
+ /**
1084
+ * Останавливает уровень бакета. Общая очередь направления гасится пулом.
1085
+ *
1086
+ */
1087
+ stop() {
1088
+ this.#generation += 1;
1089
+ this.#gate.stop();
1090
+ }
1091
+ /** Лимит бакета: сказанный сервером, иначе табличный. */
1092
+ #capacity() {
1093
+ return this.#limit ?? this.#seedLimit;
1094
+ }
1095
+ /** Списывает токен на уходящий запрос и придерживает бакет до следующего. */
1096
+ #spend() {
1097
+ const capacity = this.#capacity();
1098
+ if (capacity === void 0) return;
1099
+ this.#refill(capacity);
1100
+ this.#tokens -= 1;
1101
+ this.#armPause(capacity);
1102
+ }
1103
+ /** Возвращает накопленное с прошлой проверки: `limit` единиц за минуту. */
1104
+ #refill(capacity) {
1105
+ const now = this.#clock.now();
1106
+ if (this.#tokensAt !== void 0) {
1107
+ const restored = (now - this.#tokensAt) * capacity / RATE_LIMIT_WINDOW;
1108
+ this.#tokens = Math.min(capacity, this.#tokens + restored);
1109
+ }
1110
+ this.#tokensAt = now;
1111
+ }
1112
+ /** Держит бакет, пока не накопится хотя бы один токен. */
1113
+ #armPause(capacity) {
1114
+ if (this.#tokens >= 1) return 0;
1115
+ const wait = Math.ceil((1 - this.#tokens) * RATE_LIMIT_WINDOW / capacity);
1116
+ this.#gate.pause(wait);
1117
+ return wait;
1118
+ }
1119
+ };
1120
+ /** Лимит бакета до первого ответа: поправка пользователя важнее табличного значения. */
1121
+ function seedLimit(bucket, options) {
1122
+ const override = options.bucketOverrides[bucket]?.limit;
1123
+ if (override !== void 0) return override;
1124
+ return options.bucketLimits[bucket];
1125
+ }
1126
+ /**
1127
+ * Очереди по парам «направление — серверный счётчик частоты».
1128
+ *
1129
+ * Направление — origin уже разрешённого URL: разные локальные имена одного хоста делят
1130
+ * лимит, а запрос с разовым внешним `baseUrl` не попадает в основную очередь. Мощность
1131
+ * карты ограничена каталогом операций, поэтому ни TTL, ни вытеснение не нужны.
1132
+ *
1133
+ * @internal
1134
+ */
1135
+ var RequestQueuePool = class {
1136
+ #options;
1137
+ #clock;
1138
+ /** Ключ `undefined` — основная очередь внутренних клиентов без известного направления. */
1139
+ #destinations = /* @__PURE__ */ new Map();
1140
+ constructor(options, clock = systemClock) {
1141
+ this.#options = options;
1142
+ this.#clock = clock;
1143
+ }
1144
+ /** Очередь бакета на направлении. При `buckets: false` бакет всегда `default`. */
1145
+ for(destination, bucket) {
1146
+ const fallback = this.#options.defaultBucket;
1147
+ const name = this.#options.buckets ? bucket ?? fallback : fallback;
1148
+ let entry = this.#destinations.get(destination);
1149
+ if (!entry) {
1150
+ entry = {
1151
+ shared: new RequestQueue(this.#options, this.#clock),
1152
+ buckets: /* @__PURE__ */ new Map()
1153
+ };
1154
+ this.#destinations.set(destination, entry);
1155
+ }
1156
+ let queue = entry.buckets.get(name);
1157
+ if (!queue) {
1158
+ queue = new BucketQueue(destination, name, entry.shared, this.#options, this.#clock);
1159
+ entry.buckets.set(name, queue);
1160
+ }
1161
+ return queue;
1162
+ }
1163
+ /** Снимки всех бакетов, о которых что-то известно. */
1164
+ states() {
1165
+ const states = [];
1166
+ for (const entry of this.#destinations.values()) for (const queue of entry.buckets.values()) states.push(queue.state());
1167
+ return states;
1168
+ }
1169
+ /**
1170
+ * Останавливает оба уровня всех очередей: ожидающие задачи отклоняются, а состояние
1171
+ * счётчиков и отложенные паузы сохраняются до следующего запуска — путь `close()`.
1172
+ */
1173
+ stop() {
1174
+ for (const entry of this.#destinations.values()) {
1175
+ for (const queue of entry.buckets.values()) queue.stop();
1176
+ entry.shared.stop();
1177
+ }
1178
+ }
1179
+ /** Останавливает очереди и забывает всё, что известно о счётчиках, — путь `dispose()`. */
1180
+ clear() {
1181
+ this.stop();
1182
+ this.#destinations.clear();
1183
+ }
1184
+ };
1185
+ //#endregion
1186
+ //#region src/core/services.ts
1187
+ /**
1188
+ * Накладывает пользовательское определение на встроенное.
1189
+ *
1190
+ * `auth` и `headers` наследуются от встроенного, пока ключ не задан явно: авторизация
1191
+ * относится к сервису, а не к адресу. Явный `undefined` возвращает вывод по хосту.
1192
+ *
1193
+ * @internal
1194
+ */
1195
+ function mergeService(builtIn, override) {
1196
+ const auth = "auth" in override ? override.auth : builtIn.auth;
1197
+ const headers = "headers" in override ? override.headers : builtIn.headers;
1198
+ return {
1199
+ name: builtIn.name,
1200
+ baseUrl: override.baseUrl,
1201
+ ...headers === void 0 ? {} : { headers },
1202
+ ...auth === void 0 ? {} : { auth }
1203
+ };
1204
+ }
1205
+ /**
1206
+ * Именованные сервисы клиента.
1207
+ *
1208
+ * @internal
1209
+ */
1210
+ var ServiceRegistry = class {
1211
+ #services = /* @__PURE__ */ new Map();
1212
+ /** Хост основного API. */
1213
+ #primaryHost;
1214
+ /** @param primaryBaseUrl базовый URL клиента */
1215
+ constructor(primaryBaseUrl) {
1216
+ this.#primaryHost = primaryBaseUrl ? hostOf(primaryBaseUrl) : "";
1217
+ }
1218
+ /**
1219
+ * Регистрирует сервис. Имя очищается от краевых пробелов, базовый URL приводится
1220
+ * к каноничному виду, а незаданный `auth` выводится из хоста.
1221
+ *
1222
+ * @throws {ItdConfigError} если имя пустое, имя занято или `baseUrl` не абсолютный URL
1223
+ */
1224
+ define(definition) {
1225
+ const raw = definition?.name;
1226
+ if (typeof raw !== "string" || raw.trim() === "") throw new ItdConfigError("У сервиса должно быть непустое имя");
1227
+ const name = raw.trim();
1228
+ if (this.#services.has(name)) throw new ItdConfigError(`Сервис «${name}» уже зарегистрирован и не может быть заменён`);
1229
+ const baseUrl = normalizeBaseUrl(definition.baseUrl);
1230
+ if (definition.auth !== void 0 && typeof definition.auth !== "boolean") throw new ItdConfigError(`services.${name}.auth должен быть boolean`);
1231
+ let headers;
1232
+ if (definition.headers !== void 0) {
1233
+ if (typeof definition.headers !== "object" || definition.headers === null || Array.isArray(definition.headers)) throw new ItdConfigError(`services.${name}.headers должен быть объектом строк`);
1234
+ for (const [header, value] of Object.entries(definition.headers)) if (typeof value !== "string") throw new ItdConfigError(`services.${name}.headers.${header} должен быть строкой`);
1235
+ headers = Object.freeze({ ...definition.headers });
1236
+ }
1237
+ const normalized = {
1238
+ ...definition,
1239
+ name,
1240
+ baseUrl,
1241
+ auth: definition.auth ?? isSameSite(this.#primaryHost, hostOf(baseUrl)),
1242
+ ...headers ? { headers } : {}
1243
+ };
1244
+ this.#services.set(name, Object.freeze(normalized));
1245
+ }
1246
+ /** Определение сервиса либо `undefined`, если такого нет. */
1247
+ get(name) {
1248
+ return this.#services.get(name);
1249
+ }
1250
+ /** Зарегистрирован ли сервис с таким именем. */
1251
+ has(name) {
1252
+ return this.#services.has(name);
1253
+ }
1254
+ /**
1255
+ * Определение сервиса.
1256
+ *
1257
+ * @throws {ItdConfigError} если сервис не зарегистрирован
1258
+ */
1259
+ require(name) {
1260
+ const service = this.#services.get(name);
1261
+ if (!service) {
1262
+ const known = [...this.#services.keys()];
1263
+ throw new ItdConfigError(`Сервис «${name}» не зарегистрирован. ` + (known.length > 0 ? `Известны: ${known.join(", ")}` : "Зарегистрируйте его через itd.defineService() или опцию services"));
1264
+ }
1265
+ return service;
1266
+ }
1267
+ /**
1268
+ * Базовый URL сервиса.
1269
+ *
1270
+ * @throws {ItdConfigError} если сервис не зарегистрирован
1271
+ */
1272
+ resolveBaseUrl(name) {
1273
+ return this.require(name).baseUrl;
1274
+ }
1275
+ /**
1276
+ * Принадлежит ли URL основному хосту клиента или его поддомену.
1277
+ *
1278
+ * Используется для безопасного значения по умолчанию у разового `baseUrl`: Bearer-токен
1279
+ * не должен уходить на посторонний хост без явного `skipAuth: false`.
1280
+ *
1281
+ * @internal
1282
+ */
1283
+ isPrimarySite(baseUrl) {
1284
+ return isSameSite(this.#primaryHost, hostOf(baseUrl));
1285
+ }
1286
+ };
1287
+ //#endregion
1288
+ //#region src/core/execution/pipeline.ts
1289
+ const REQUEST_ATTEMPT_STATE = Symbol("itd-api.request-attempt-state");
1290
+ const REQUEST_QUEUE_KEY = Symbol("itd-api.request-queue-key");
1291
+ const DISPOSE_CLEANUP_REQUEST = Symbol("itd-api.dispose-cleanup-request");
1292
+ /** Один раз присваивает низкоуровневому запросу семантический ID до входа в middleware. */
1293
+ function identifyRequest(request) {
1294
+ return request.operationId === void 0 ? {
1295
+ ...request,
1296
+ operationId: "raw"
1297
+ } : request;
1298
+ }
1299
+ /** Привязывает счётчик транспортных попыток к одной логической операции. @internal */
1300
+ function trackRequestAttempts(request) {
1301
+ if (request[REQUEST_ATTEMPT_STATE]) return request;
1302
+ return {
1303
+ ...request,
1304
+ [REQUEST_ATTEMPT_STATE]: { value: 0 }
1305
+ };
1306
+ }
1307
+ /** Начинает следующую фактическую транспортную попытку логической операции. @internal */
1308
+ function beginTransportAttempt(request) {
1309
+ const tracked = trackRequestAttempts(request);
1310
+ const state = tracked[REQUEST_ATTEMPT_STATE];
1311
+ if (!state) throw new Error("request attempt state was not initialized");
1312
+ state.value += 1;
1313
+ return {
1314
+ ...tracked,
1315
+ attempt: state.value
1316
+ };
1317
+ }
1318
+ /** Возвращает номер последней начатой транспортной попытки. @internal */
1319
+ function currentTransportAttempt(request) {
1320
+ return request[REQUEST_ATTEMPT_STATE]?.value ?? 0;
1321
+ }
1322
+ /**
1323
+ * Вычисляет ключ очереди один раз на логическую операцию.
1324
+ *
1325
+ * Ключ спрашивают трижды: при постановке в очередь, при чтении заголовков ответа и при
1326
+ * паузе после `429`. Значение пишется прямо в объект запроса — слои ниже копируют его
1327
+ * через spread, и перечислимое символьное поле переходит в копии.
1328
+ *
1329
+ * @internal
1330
+ */
1331
+ function requestQueueKey(request, compute) {
1332
+ const internal = request;
1333
+ const cached = internal[REQUEST_QUEUE_KEY];
1334
+ if (cached) return cached;
1335
+ const key = compute(request);
1336
+ internal[REQUEST_QUEUE_KEY] = key;
1337
+ return key;
1338
+ }
1339
+ /** Помечает запрос как часть внутренней финализации уже начатого `dispose()`. @internal */
1340
+ function markDisposeCleanupRequest(request) {
1341
+ return {
1342
+ ...request,
1343
+ [DISPOSE_CLEANUP_REQUEST]: true
1344
+ };
1345
+ }
1346
+ /** Разрешено ли запросу завершать внутреннюю очистку после перехода клиента в terminal state. @internal */
1347
+ function isDisposeCleanupRequest(request) {
1348
+ return request[DISPOSE_CLEANUP_REQUEST] === true;
1349
+ }
1350
+ /**
1351
+ * Собирает слои в один обработчик.
1352
+ *
1353
+ * Первый слой оказывается самым внешним. Порядок задаётся внутренним client runtime.
1354
+ *
1355
+ * @example
1356
+ * ```ts
1357
+ * const handler = composePipeline(
1358
+ * [plugins, services, retry, authRecovery, authPreparation, queue, attempt, authHeaders],
1359
+ * transport.send,
1360
+ * );
1361
+ * ```
1362
+ */
1363
+ function composePipeline(middlewares, final) {
1364
+ return middlewares.reduceRight((next, middleware) => (request) => middleware(request, next), final);
1365
+ }
1366
+ /** Добавляет заголовки слоя, не трогая пользовательские. */
1367
+ function withLayerHeaders(request, headers) {
1368
+ return {
1369
+ ...request,
1370
+ layerHeaders: {
1371
+ ...request.layerHeaders,
1372
+ ...headers
1373
+ }
1374
+ };
1375
+ }
1376
+ //#endregion
1377
+ //#region src/core/execution/http.ts
1378
+ /**
1379
+ * Точка входа ресурсов в конвейер запросов.
1380
+ *
1381
+ * Принимает готовый обработчик — цепочку слоёв поверх транспорта, собранную
1382
+ * во внутреннем runtime клиента, — и отдаёт ресурсам методы `request`/`operation`.
1383
+ * О слоях и их порядке ресурсы не знают.
1384
+ */
1385
+ var HttpClient = class {
1386
+ #handler;
1387
+ #baseUrl;
1388
+ #catalog;
1389
+ constructor(deps) {
1390
+ this.#handler = deps.handler;
1391
+ this.#baseUrl = deps.baseUrl;
1392
+ this.#catalog = deps.catalog;
1393
+ }
1394
+ /** Метод операции из каталога. Отсутствие описания — ошибка сборки клиента, не запроса. */
1395
+ #methodOf(operationId) {
1396
+ const method = this.#catalog.methodOf(operationId);
1397
+ if (method === void 0) throw new ItdConfigError(`каталог операций не знает «${operationId}»`);
1398
+ return method;
1399
+ }
1400
+ /** Базовый URL, к которому обращается клиент. */
1401
+ get baseUrl() {
1402
+ return this.#baseUrl;
1403
+ }
1404
+ /**
1405
+ * Выполняет запрос к API через собранный конвейер.
1406
+ *
1407
+ * @typeParam T ожидаемая форма ответа после снятия обёртки `{ data: … }`
1408
+ * @throws {ItdApiError} если сервер ответил статусом ≥ 400
1409
+ * @throws {ItdTimeoutError} если истёк таймаут
1410
+ * @throws {ItdAbortError} если запрос отменён через `signal`
1411
+ * @throws {ItdNetworkError} если запрос не дошёл до сервера
1412
+ */
1413
+ request(options) {
1414
+ return this.#handler(identifyRequest(options));
1415
+ }
1416
+ /** Выполняет встроенную семантическую операцию, подставляя её HTTP-метод из каталога. */
1417
+ operation(operationId, options) {
1418
+ return this.request({
1419
+ ...options,
1420
+ operationId,
1421
+ method: this.#methodOf(operationId)
1422
+ });
1423
+ }
1424
+ /** Выполняет внутреннюю операцию финализации после начала `ItdClient.dispose()`. @internal */
1425
+ cleanupOperation(operationId, options) {
1426
+ return this.request(markDisposeCleanupRequest({
1427
+ ...options,
1428
+ operationId,
1429
+ method: this.#methodOf(operationId)
1430
+ }));
1431
+ }
1432
+ };
1433
+ //#endregion
1434
+ //#region src/core/plugins/hooks.ts
1435
+ /** Проверяет наличие публичного hook, не создавая дорогой контекст. @internal */
1436
+ function hasRequestHook(hooks, field) {
1437
+ return hooks[field] !== void 0;
1438
+ }
1439
+ /** Последовательно вызывает публичный hook конструктора. @internal */
1440
+ async function dispatchRequestHook(hooks, field, context) {
1441
+ const hook = hooks[field];
1442
+ await hook?.(context);
1443
+ }
1444
+ //#endregion
1445
+ //#region src/core/operation.ts
1446
+ /** Семантическая безопасность автоматического повтора операции. */
1447
+ const RetrySafety = Object.freeze({
1448
+ /** Автоматический повтор не создаёт неприемлемого эффекта; обычно это чтение. */
1449
+ Safe: "safe",
1450
+ /** Повтор операции приводит к тому же состоянию, что и один вызов. */
1451
+ Idempotent: "idempotent",
1452
+ /** Повтор может создать ещё один побочный эффект. */
1453
+ Unsafe: "unsafe"
1454
+ });
1455
+ //#endregion
1456
+ //#region src/core/scheduling/retry.ts
1457
+ /** Методы raw-запроса, безопасность которых гарантирована самим HTTP-контрактом. */
1458
+ const SAFE_RAW_METHODS = /* @__PURE__ */ new Set([
1459
+ "GET",
1460
+ "HEAD",
1461
+ "OPTIONS"
1462
+ ]);
1463
+ /** Может ли транспорт заново получить эквивалентное тело запроса. */
1464
+ function isRequestBodyReplayable(request) {
1465
+ if (request.bodyFactory !== void 0) return true;
1466
+ return !(typeof ReadableStream !== "undefined" && request.body instanceof ReadableStream);
1467
+ }
1468
+ /** Разрешает retrySafety запроса из явного override, каталога либо raw fallback. */
1469
+ function resolveRetryPolicy(request, catalog) {
1470
+ let retrySafety;
1471
+ const known = catalog.retrySafetyOf(request.operationId);
1472
+ if (request.retrySafety !== void 0) retrySafety = request.retrySafety;
1473
+ else if (known !== void 0) retrySafety = known;
1474
+ else if (request.operationId === "raw" && SAFE_RAW_METHODS.has(request.method.toUpperCase())) retrySafety = RetrySafety.Safe;
1475
+ else retrySafety = RetrySafety.Unsafe;
1476
+ return {
1477
+ operationId: request.operationId,
1478
+ retrySafety,
1479
+ bodyReplayable: isRequestBodyReplayable(request),
1480
+ method: request.method.toUpperCase(),
1481
+ path: request.path
1482
+ };
1483
+ }
1484
+ /**
1485
+ * Стоит ли повторять запрос после этой ошибки.
1486
+ *
1487
+ * `429` гарантирует, что операция не была обработана, поэтому допускает даже unsafe retry.
1488
+ * Обрыв сети, timeout и `5xx` такой гарантии не дают и требуют safe/idempotent операции.
1489
+ * Ошибка подготовки файла возникает до обращения к серверу и потому зависит только от
1490
+ * собственного признака retryable и повторяемости тела.
1491
+ */
1492
+ function isRetryable(error, policy) {
1493
+ if (error instanceof ItdAbortError || !policy.bodyReplayable) return false;
1494
+ const repeatableOperation = policy.retrySafety === RetrySafety.Safe || policy.retrySafety === RetrySafety.Idempotent;
1495
+ if (error instanceof ItdApiError) {
1496
+ if (error.status === 429) return true;
1497
+ if (error.status >= 500) return repeatableOperation;
1498
+ return false;
1499
+ }
1500
+ if (error instanceof ItdNetworkError || error instanceof ItdTimeoutError) return repeatableOperation;
1501
+ if (error instanceof ItdFileError) return error.retryable;
1502
+ return false;
1503
+ }
1504
+ /** Экспоненциальная пауза со случайным разбросом. */
1505
+ function backoffDelay(retryAttempt, options, random) {
1506
+ const exponential = options.baseDelay * 2 ** (retryAttempt - 1);
1507
+ const capped = Math.min(exponential, options.maxDelay);
1508
+ const spread = capped * options.jitter * (random() * 2 - 1);
1509
+ return Math.max(0, Math.round(capped + spread));
1510
+ }
1511
+ /**
1512
+ * Собирает планировщик обычных повторов для транспорта.
1513
+ *
1514
+ * Поведение при `Retry-After`: пауза, названная сервером, соблюдается точно. Если сервер
1515
+ * просит ждать дольше `maxDelay`, повтор не выполняется — вызывающий код получает ошибку.
1516
+ * Rate-limit ladder основного pipeline ведёт отдельный счётчик и обрабатывается middleware.
1517
+ */
1518
+ function createRetryScheduler(options, random = Math.random) {
1519
+ return (error, retryAttempt, policy) => {
1520
+ if (retryAttempt >= options.attempts) return void 0;
1521
+ if (error instanceof ItdAbortError || !policy.bodyReplayable) return void 0;
1522
+ if (options.shouldRetry) return options.shouldRetry(error, retryAttempt, policy) ? backoffDelay(retryAttempt, options, random) : void 0;
1523
+ if (!isRetryable(error, policy)) return void 0;
1524
+ if (error instanceof ItdApiError && error.retryAfter !== void 0) return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
1525
+ return backoffDelay(retryAttempt, options, random);
1526
+ };
1527
+ }
1528
+ //#endregion
1529
+ //#region src/core/execution/middleware.ts
1530
+ /** Ожидание повтора, которое уважает отмену запроса. */
1531
+ function sleep(clock, ms, signal) {
1532
+ if (!signal) return new Promise((resolve) => clock.schedule(resolve, ms));
1533
+ if (signal.aborted) return Promise.reject(new ItdAbortError("Запрос отменён во время ожидания повтора"));
1534
+ return new Promise((resolve, reject) => {
1535
+ const cancel = clock.schedule(() => {
1536
+ signal.removeEventListener("abort", onAbort);
1537
+ resolve();
1538
+ }, ms);
1539
+ const onAbort = () => {
1540
+ cancel();
1541
+ reject(new ItdAbortError("Запрос отменён во время ожидания повтора"));
1542
+ };
1543
+ signal.addEventListener("abort", onAbort, { once: true });
1544
+ });
1545
+ }
1546
+ /**
1547
+ * Слой очереди: ограничение конкурентности и частоты.
1548
+ *
1549
+ * Должен стоять непосредственно вокруг одной транспортной попытки: тогда ожидание retry
1550
+ * не занимает слот, а каждый реальный HTTP-вызов заново учитывается ограничителем частоты.
1551
+ *
1552
+ * `skipQueue` оставляет продвинутому вызывающему явный способ обойти планировщик.
1553
+ */
1554
+ function createQueueMiddleware(schedule) {
1555
+ return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
1556
+ }
1557
+ /**
1558
+ * Слой плагинов.
1559
+ *
1560
+ * Стоит снаружи повторов и очереди: operation transformers видят запрос и ответ по одному
1561
+ * разу, иначе, например, текст поста зашифруется дважды. Здесь же к операции привязывается
1562
+ * snapshot attempt interceptors; сами они выполняются транспортом на каждой попытке.
1563
+ */
1564
+ function createPluginsMiddleware(plugins) {
1565
+ return (request, next) => plugins.run(request, next);
1566
+ }
1567
+ /**
1568
+ * Слой сервисов.
1569
+ *
1570
+ * Запросу с полем `service` подставляет хост сервиса, его заголовки и `skipAuth`, если
1571
+ * сервис объявлен публичным. Заданный у запроса `baseUrl` не трогает.
1572
+ *
1573
+ * Стоит снаружи повторов и авторизации, чтобы выставленный здесь `skipAuth` был ей виден.
1574
+ */
1575
+ function createServicesMiddleware(registry) {
1576
+ return async (request, next) => {
1577
+ const service = request.service === void 0 ? void 0 : registry.require(request.service);
1578
+ let prepared = request;
1579
+ if (request.baseUrl !== void 0) {
1580
+ const baseUrl = normalizeBaseUrl(request.baseUrl);
1581
+ if (baseUrl !== request.baseUrl) prepared = {
1582
+ ...prepared,
1583
+ baseUrl
1584
+ };
1585
+ if (!(service?.baseUrl === baseUrl ? service.auth !== false : registry.isPrimarySite(baseUrl)) && prepared.skipAuth === void 0) prepared = {
1586
+ ...prepared,
1587
+ skipAuth: true
1588
+ };
1589
+ }
1590
+ if (!service) return next(prepared);
1591
+ if (prepared.baseUrl === void 0) prepared = {
1592
+ ...prepared,
1593
+ baseUrl: service.baseUrl
1594
+ };
1595
+ if (service.headers) prepared = withLayerHeaders(prepared, service.headers);
1596
+ if (service.auth === false && prepared.skipAuth === void 0) prepared = {
1597
+ ...prepared,
1598
+ skipAuth: true
1599
+ };
1600
+ return next(prepared);
1601
+ };
1602
+ }
1603
+ /**
1604
+ * Подготавливает auth state до входа транспортной попытки в очередь.
1605
+ *
1606
+ * Загрузка storage, внешний `getToken` и ленивый sign-in могут быть асинхронными; sign-in
1607
+ * сам входит в ту же queue. Поэтому эти действия обязаны завершиться до захвата её слота.
1608
+ */
1609
+ function createAuthPreparationMiddleware(auth) {
1610
+ return async (request, next) => {
1611
+ if (!request.skipAuth) await auth.prepare();
1612
+ return next(request);
1613
+ };
1614
+ }
1615
+ /**
1616
+ * Добавляет уже подготовленные заголовки непосредственно перед transport.
1617
+ *
1618
+ * Слой стоит внутри queue, поэтому источник заголовков обязан быть синхронным и не
1619
+ * запускать I/O — это выражено типом {@link AuthProvider.currentHeaders}. Если token
1620
+ * изменился, пока запрос ждал slot, будет использовано новое значение.
1621
+ */
1622
+ function createAuthHeadersMiddleware(auth) {
1623
+ return (request, next) => {
1624
+ if (request.skipAuth) return next(request);
1625
+ const headers = auth.currentHeaders();
1626
+ return next(Object.keys(headers).length > 0 ? withLayerHeaders(request, headers) : request);
1627
+ };
1628
+ }
1629
+ /**
1630
+ * Нумерует фактические входы в transport для одной логической операции.
1631
+ *
1632
+ * Слой находится внутри auth recovery: повтор исходного запроса после успешного refresh
1633
+ * получает следующий номер, а сам `auth.refresh` ведёт собственный счётчик.
1634
+ */
1635
+ function createAttemptMiddleware() {
1636
+ return (request, next) => next(beginTransportAttempt(request));
1637
+ }
1638
+ /**
1639
+ * Обрабатывает `401`: обновляет токен и повторяет транспортную попытку ровно один раз.
1640
+ *
1641
+ * Стоит снаружи подготовки auth и очереди, поэтому не удерживает её slot во время refresh.
1642
+ * Его `next` включает все эти слои: повтор заново готовит auth state и планируется.
1643
+ */
1644
+ function createAuthRecoveryMiddleware(auth) {
1645
+ return async (request, next) => {
1646
+ try {
1647
+ return await next(request);
1648
+ } catch (error) {
1649
+ if (request.skipAuthRefresh || !isItdApiError(error) || error.status !== 401) throw error;
1650
+ if (!await auth.recover()) throw error;
1651
+ return next({
1652
+ ...request,
1653
+ skipAuthRefresh: true
1654
+ });
1655
+ }
1656
+ };
1657
+ }
1658
+ /**
1659
+ * Выбирает планировщик отката для конкретного запроса.
1660
+ *
1661
+ * `retry` у запроса переопределяет глобальную настройку: `false` выключает повторы,
1662
+ * объект задаёт свои. Обработка `429` от этого не зависит — она общая.
1663
+ */
1664
+ function resolveBackoff(retry, global) {
1665
+ if (retry === void 0) return global;
1666
+ if (retry === false) return void 0;
1667
+ const resolved = resolveRetry(retry);
1668
+ return resolved ? createRetryScheduler(resolved) : void 0;
1669
+ }
1670
+ /**
1671
+ * Слой повторов.
1672
+ *
1673
+ * Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
1674
+ * всей очереди; сетевые сбои и `5xx` — экспоненциальным откатом. Настройка `retry`
1675
+ * у отдельного запроса имеет приоритет над глобальной.
1676
+ */
1677
+ function createRetryMiddleware(deps) {
1678
+ const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
1679
+ const nextDelay = (error, retryAttempt, rateLimitAttempt, request, policy, backoff) => {
1680
+ if (isItdRateLimitError(error)) {
1681
+ if (!policy.bodyReplayable) return void 0;
1682
+ const wait = error.retryAfter ?? deps.rateLimitDelays[rateLimitAttempt - 1];
1683
+ if (wait === void 0) return void 0;
1684
+ deps.pauseQueue?.(wait, request);
1685
+ deps.logger?.debug(`лимит частоты, повтор ${rateLimitAttempt} через ${wait} мс`);
1686
+ return wait;
1687
+ }
1688
+ return backoff?.(error, retryAttempt, policy);
1689
+ };
1690
+ return async (request, next) => {
1691
+ const trackedRequest = trackRequestAttempts(request);
1692
+ const method = request.method.toUpperCase();
1693
+ const policy = resolveRetryPolicy(request, deps.catalog);
1694
+ const backoff = resolveBackoff(request.retry, globalScheduler);
1695
+ let retryAttempt = 0;
1696
+ let rateLimitAttempt = 0;
1697
+ for (;;) try {
1698
+ return await next(trackedRequest);
1699
+ } catch (error) {
1700
+ const transportAttempt = currentTransportAttempt(trackedRequest);
1701
+ if (isItdRateLimitError(error)) rateLimitAttempt += 1;
1702
+ else retryAttempt += 1;
1703
+ const delay = nextDelay(error, retryAttempt, rateLimitAttempt, trackedRequest, policy, backoff);
1704
+ if (delay === void 0) throw error;
1705
+ await dispatchRequestHook(deps.hooks, "onRetry", {
1706
+ operationId: request.operationId,
1707
+ method,
1708
+ path: request.path,
1709
+ url: deps.buildUrl(request),
1710
+ headers: new Headers({
1711
+ ...request.layerHeaders,
1712
+ ...request.headers
1713
+ }),
1714
+ attempt: transportAttempt,
1715
+ error,
1716
+ delay
1717
+ });
1718
+ deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${transportAttempt + 1} через ${delay} мс`);
1719
+ await sleep(deps.clock ?? systemClock, delay, request.signal);
1720
+ }
1721
+ };
1722
+ }
1723
+ //#endregion
1724
+ //#region src/core/redact.ts
1725
+ /** Заголовки, значение которых нельзя писать в лог целиком. */
1726
+ const SECRET_HEADERS = /* @__PURE__ */ new Set([
1727
+ "authorization",
1728
+ "cookie",
1729
+ "set-cookie",
1730
+ "x-api-key"
1731
+ ]);
1732
+ /** Поля тела запроса, значение которых нельзя писать в лог. */
1733
+ const SECRET_FIELDS = /* @__PURE__ */ new Set([
1734
+ "password",
1735
+ "oldpassword",
1736
+ "newpassword",
1737
+ "accesstoken",
1738
+ "refreshtoken",
1739
+ "currentpassword",
1740
+ "flowtoken",
1741
+ "token",
1742
+ "turnstiletoken",
1743
+ "otp"
1744
+ ]);
1745
+ /** Query-параметры, содержащие секреты. */
1746
+ const SECRET_QUERY_PARAMS = /* @__PURE__ */ new Set(["token", "access_token"]);
1747
+ /**
1748
+ * Прячет середину секрета, оставляя концы для сопоставления.
1749
+ *
1750
+ * @example
1751
+ * ```ts
1752
+ * maskSecret('eyJhbGciOiJIUzI1NiJ9.abc'); // 'eyJh…(24)…abc'
1753
+ * ```
1754
+ */
1755
+ function maskSecret(value) {
1756
+ if (value.length <= 8) return "…";
1757
+ return `${value.slice(0, 4)}…(${value.length})…${value.slice(-3)}`;
1758
+ }
1759
+ /** Маскирует секретные query-параметры URL перед записью в логи и ошибки. */
1760
+ function redactUrl(url) {
1761
+ try {
1762
+ const parsed = new URL(url);
1763
+ const entries = [...parsed.searchParams.entries()];
1764
+ parsed.search = "";
1765
+ for (const [name, value] of entries) parsed.searchParams.append(name, SECRET_QUERY_PARAMS.has(name.toLowerCase()) ? maskSecret(value) : value);
1766
+ return parsed.toString();
1767
+ } catch {
1768
+ return url.replace(/([?&](?:token|access_token)=)[^&#]*/gi, "$1[скрыто]");
1769
+ }
1770
+ }
1771
+ /**
1772
+ * Готовит заголовки к записи в лог.
1773
+ *
1774
+ * `Authorization` и `Cookie` маскируются: логи часто уезжают в системы сбора,
1775
+ * и токен доступа в них попадать не должен.
1776
+ */
1777
+ function redactHeaders(headers) {
1778
+ const result = {};
1779
+ headers.forEach((value, name) => {
1780
+ if (SECRET_HEADERS.has(name.toLowerCase())) {
1781
+ const spaceAt = value.indexOf(" ");
1782
+ result[name] = spaceAt > 0 ? `${value.slice(0, spaceAt)} ${maskSecret(value.slice(spaceAt + 1))}` : maskSecret(value);
1783
+ return;
1784
+ }
1785
+ result[name] = value;
1786
+ });
1787
+ return result;
1788
+ }
1789
+ /**
1790
+ * Готовит тело запроса к записи в лог: пароли, токены и коды OTP заменяются заглушкой.
1791
+ *
1792
+ * Обходит вложенные объекты и массивы. `FormData` и бинарные тела не раскрываются вовсе.
1793
+ */
1794
+ function redactBody(body) {
1795
+ if (body === null || body === void 0) return body;
1796
+ if (typeof FormData !== "undefined" && body instanceof FormData) return "[FormData]";
1797
+ if (isBlob(body)) return "[Blob]";
1798
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return "[binary]";
1799
+ if (Array.isArray(body)) return body.map(redactBody);
1800
+ if (typeof body === "object") {
1801
+ const result = {};
1802
+ for (const [key, value] of Object.entries(body)) result[key] = SECRET_FIELDS.has(key.toLowerCase()) ? "[скрыто]" : redactBody(value);
1803
+ return result;
1804
+ }
1805
+ return body;
1806
+ }
1807
+ //#endregion
1808
+ //#region src/core/unwrap.ts
1809
+ /**
1810
+ * Снимает внешнюю обёртку `{ data: … }`.
1811
+ *
1812
+ * Это единственное преобразование ответа, которое делает транспортный слой. Формы ответов у
1813
+ * итд.com непоследовательны: одни эндпоинты заворачивают полезную нагрузку в `data`, другие
1814
+ * отдают её напрямую (`GET /api/notifications/` → `{ notifications, hasMore }`), третьи кладут
1815
+ * список в поле по имени сущности. Угадывать имя поля опасно, поэтому его разбирает каждый
1816
+ * метод ресурса явно, а здесь снимается только однозначная обёртка.
1817
+ *
1818
+ * Обёртка считается обёрткой, лишь когда `data` — **единственный** ключ объекта. Ответ
1819
+ * `{ data: …, meta: … }` вернётся как есть.
1820
+ *
1821
+ * @example
1822
+ * ```ts
1823
+ * unwrapData({ data: { posts: [] } }); // { posts: [] }
1824
+ * unwrapData({ notifications: [], hasMore }); // без изменений
1825
+ * unwrapData({ data: [], hasMore: true }); // без изменений — ключей больше одного
1826
+ * ```
1827
+ */
1828
+ function unwrapData(body) {
1829
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return body;
1830
+ const keys = Object.keys(body);
1831
+ if (keys.length !== 1 || keys[0] !== "data") return body;
1832
+ return body.data;
1833
+ }
1834
+ /**
1835
+ * Обычный объект — не `null`, не массив.
1836
+ *
1837
+ * Живёт здесь, а не в каждом разборщике ответа: одна и та же проверка нужна и фабрике
1838
+ * ошибок, и приведению уведомлений.
1839
+ */
1840
+ function isRecord(value) {
1841
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1842
+ }
1843
+ /** Непустая строка либо `undefined`. Отличается от {@link pickString} тем, что берёт значение, а не поле. */
1844
+ function asString(value) {
1845
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1846
+ }
1847
+ /**
1848
+ * Достаёт поле-список из ответа, уже прошедшего {@link unwrapData}.
1849
+ *
1850
+ * Если поля нет или оно не массив, возвращается пустой массив: сервер иногда опускает
1851
+ * пустые коллекции, и падать из-за этого библиотека не должна.
1852
+ */
1853
+ function pickArray(source, field) {
1854
+ if (typeof source !== "object" || source === null) return [];
1855
+ const value = source[field];
1856
+ return Array.isArray(value) ? value : [];
1857
+ }
1858
+ /** Достаёт объект по имени поля. Возвращает `undefined`, если это не объект. */
1859
+ function pickObject(source, field) {
1860
+ if (typeof source !== "object" || source === null) return void 0;
1861
+ const value = source[field];
1862
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1863
+ return value;
1864
+ }
1865
+ /** Достаёт булево поле. Возвращает `fallback`, если поля нет или тип не тот. */
1866
+ function pickBoolean(source, field, fallback = false) {
1867
+ if (typeof source !== "object" || source === null) return fallback;
1868
+ const value = source[field];
1869
+ return typeof value === "boolean" ? value : fallback;
1870
+ }
1871
+ /** Достаёт числовое поле. Возвращает `fallback`, если поля нет или значение не конечное число. */
1872
+ function pickNumber(source, field, fallback) {
1873
+ if (typeof source !== "object" || source === null) return fallback;
1874
+ const value = source[field];
1875
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1876
+ }
1877
+ /** Достаёт строковое поле. Возвращает `undefined`, если поля нет, оно пустое или не строка. */
1878
+ function pickString(source, field) {
1879
+ if (typeof source !== "object" || source === null) return void 0;
1880
+ const value = source[field];
1881
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1882
+ }
1883
+ //#endregion
1884
+ //#region src/core/error-factory.ts
1885
+ /**
1886
+ * Сводит ошибки по полям к единой форме `{ поле: [сообщения] }`.
1887
+ *
1888
+ * Понимает две документированные формы:
1889
+ * - `errors: { email: ['…'] }` либо `errors: { email: '…' }`
1890
+ * - `violations: [{ field: 'email', message: '…' }]`
1891
+ */
1892
+ function collectFieldErrors(source) {
1893
+ const result = {};
1894
+ const errors = source.errors;
1895
+ if (isRecord(errors)) {
1896
+ for (const [field, value] of Object.entries(errors)) if (Array.isArray(value)) {
1897
+ const messages = value.filter((item) => typeof item === "string");
1898
+ if (messages.length > 0) result[field] = messages;
1899
+ } else if (typeof value === "string") result[field] = [value];
1900
+ }
1901
+ const violations = source.violations;
1902
+ if (Array.isArray(violations)) for (const violation of violations) {
1903
+ if (!isRecord(violation)) continue;
1904
+ const field = asString(violation.field) ?? asString(violation.property);
1905
+ const message = asString(violation.message);
1906
+ if (!field || !message) continue;
1907
+ const existing = result[field];
1908
+ if (existing) existing.push(message);
1909
+ else result[field] = [message];
1910
+ }
1911
+ return result;
1912
+ }
1913
+ /**
1914
+ * Разбирает тело ответа с ошибкой.
1915
+ *
1916
+ * API отдаёт ошибки в двух формах — с обёрткой `{ error: … }` и без неё, — а при сбое на
1917
+ * уровне прокси может вернуть вообще не JSON. Все три случая сводятся к одной структуре.
1918
+ *
1919
+ * @param body тело ответа: разобранный JSON, строка или `undefined`
1920
+ * @param status HTTP-статус, используется для сообщения по умолчанию
1921
+ * @param statusText текст статуса из ответа
1922
+ */
1923
+ function parseErrorBody(body, status, statusText = "") {
1924
+ const fallbackMessage = statusText ? `HTTP ${status} ${statusText}` : `HTTP ${status}`;
1925
+ if (typeof body === "string") return {
1926
+ code: "UNKNOWN_ERROR",
1927
+ message: asString(body.trim()) ?? fallbackMessage,
1928
+ detail: void 0,
1929
+ title: void 0,
1930
+ fieldErrors: {},
1931
+ userId: void 0
1932
+ };
1933
+ if (!isRecord(body)) return {
1934
+ code: "UNKNOWN_ERROR",
1935
+ message: fallbackMessage,
1936
+ detail: void 0,
1937
+ title: void 0,
1938
+ fieldErrors: {},
1939
+ userId: void 0
1940
+ };
1941
+ if (body.type === "validation") {
1942
+ const target = asString(body.on);
1943
+ return {
1944
+ code: "VALIDATION_ERROR",
1945
+ message: target ? `Проверка не пройдена: некорректные данные в «${target}»` : "Проверка входных данных не пройдена",
1946
+ detail: void 0,
1947
+ title: void 0,
1948
+ fieldErrors: {},
1949
+ userId: void 0
1950
+ };
1951
+ }
1952
+ const inner = isRecord(body.error) ? body.error : body;
1953
+ const message = asString(inner.message) ?? asString(inner.detail) ?? asString(inner.title) ?? asString(body.error) ?? fallbackMessage;
1954
+ return {
1955
+ code: asString(inner.code) ?? asString(body.code) ?? "UNKNOWN_ERROR",
1956
+ message,
1957
+ detail: asString(inner.detail),
1958
+ title: asString(inner.title),
1959
+ fieldErrors: {
1960
+ ...collectFieldErrors(body),
1961
+ ...collectFieldErrors(inner)
1962
+ },
1963
+ userId: asString(inner.userId) ?? asString(body.userId)
1964
+ };
1965
+ }
1966
+ /**
1967
+ * Переводит заголовок `Retry-After` в миллисекунды.
1968
+ *
1969
+ * Поддерживает обе формы из спецификации HTTP: число секунд и дату.
1970
+ * Возвращает `undefined`, если заголовка нет или он не разбирается.
1971
+ */
1972
+ function parseRetryAfter(header, now = Date.now()) {
1973
+ if (!header) return void 0;
1974
+ const seconds = Number(header);
1975
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
1976
+ const date = Date.parse(header);
1977
+ if (Number.isFinite(date)) return Math.max(0, date - now);
1978
+ }
1979
+ /** Читает целое число из заголовка. */
1980
+ function readIntHeader(headers, name) {
1981
+ const raw = headers?.get(name);
1982
+ if (raw === null || raw === void 0) return void 0;
1983
+ const value = Number.parseInt(raw, 10);
1984
+ return Number.isFinite(value) ? value : void 0;
1985
+ }
1986
+ /** Читает положительное целое число из заголовка. */
1987
+ function readPositiveIntHeader(headers, name) {
1988
+ const value = readIntHeader(headers, name);
1989
+ return value !== void 0 && value > 0 ? value : void 0;
1990
+ }
1991
+ /**
1992
+ * Читает сведения об ограничении частоты.
1993
+ *
1994
+ * Сервер сообщает размер окна и остаток, но **не сообщает время сброса** — поэтому
1995
+ * точный момент, когда можно повторить, из ответа не вывести.
1996
+ */
1997
+ function readRateLimit(headers) {
1998
+ return {
1999
+ limit: readPositiveIntHeader(headers, "x-ratelimit-limit"),
2000
+ remaining: readIntHeader(headers, "x-ratelimit-remaining")
2001
+ };
2002
+ }
2003
+ const REQUEST_ID_HEADERS = [
2004
+ "x-request-id",
2005
+ "x-requestid",
2006
+ "request-id",
2007
+ "x-correlation-id"
2008
+ ];
2009
+ /** Достаёт идентификатор запроса из заголовков ответа — пригодится при обращении в поддержку. */
2010
+ function getRequestId(headers) {
2011
+ if (!headers) return void 0;
2012
+ for (const name of REQUEST_ID_HEADERS) {
2013
+ const value = headers.get(name);
2014
+ if (value) return value;
2015
+ }
2016
+ }
2017
+ /** Соответствие кода ошибки конкретному классу. Приоритетнее, чем HTTP-статус. */
2018
+ const CODE_TO_CLASS = {
2019
+ VALIDATION_ERROR: ItdValidationError,
2020
+ RATE_LIMIT_EXCEEDED: ItdRateLimitError,
2021
+ UNAUTHORIZED: ItdAuthError,
2022
+ SESSION_EXPIRED: ItdAuthError,
2023
+ SESSION_REVOKED: ItdAuthError,
2024
+ SESSION_INVALID_REFRESH_TOKEN: ItdAuthError,
2025
+ REFRESH_TOKEN_MISSING: ItdAuthError,
2026
+ SESSION_NOT_FOUND: ItdAuthError,
2027
+ ACCOUNT_INVALID_CREDENTIALS: ItdAuthError,
2028
+ ACCESS_DENIED: ItdForbiddenError,
2029
+ ENTITY_NOT_FOUND: ItdNotFoundError,
2030
+ NOT_FOUND: ItdNotFoundError,
2031
+ ENTITY_ALREADY_EXISTS: ItdConflictError
2032
+ };
2033
+ /** Соответствие HTTP-статуса классу — запасной вариант, когда код ничего не говорит. */
2034
+ function classByStatus(status) {
2035
+ if (status === 401) return ItdAuthError;
2036
+ if (status === 403) return ItdForbiddenError;
2037
+ if (status === 404) return ItdNotFoundError;
2038
+ if (status === 409) return ItdConflictError;
2039
+ if (status === 422) return ItdValidationError;
2040
+ if (status === 429) return ItdRateLimitError;
2041
+ if (status >= 500) return ItdServerError;
2042
+ return ItdApiError;
2043
+ }
2044
+ /**
2045
+ * Готовит тело ответа к сохранению в `ItdApiError.raw`.
2046
+ *
2047
+ * При `422` сервер возвращает присланное тело эхом: `{ type: 'validation', on: 'body',
2048
+ * found: { email: '…', password: '…' } }`. Ошибка обычно доезжает до логов и систем сбора
2049
+ * вроде Sentry — пароль в ней оказаться не должен. Имена полей для диагностики остаются,
2050
+ * значения секретов заменяются заглушкой.
2051
+ */
2052
+ function safeRawBody(body) {
2053
+ if (!isRecord(body) || body.type !== "validation" || !isRecord(body.found)) return body;
2054
+ return {
2055
+ ...body,
2056
+ found: redactBody(body.found)
2057
+ };
2058
+ }
2059
+ /**
2060
+ * Строит типизированную ошибку из ответа сервера.
2061
+ *
2062
+ * Класс выбирается сначала по коду ошибки, затем по HTTP-статусу — так `VALIDATION_ERROR`
2063
+ * со статусом `400` всё равно станет {@link ItdValidationError}.
2064
+ */
2065
+ function createApiError(context) {
2066
+ const parsed = parseErrorBody(context.body, context.status, context.statusText);
2067
+ const rateLimit = readRateLimit(context.headers);
2068
+ const init = {
2069
+ rateLimit: rateLimit.limit,
2070
+ rateLimitRemaining: rateLimit.remaining,
2071
+ status: context.status,
2072
+ code: parsed.code,
2073
+ message: parsed.message,
2074
+ detail: parsed.detail,
2075
+ title: parsed.title,
2076
+ fieldErrors: parsed.fieldErrors,
2077
+ requestId: getRequestId(context.headers),
2078
+ method: context.method,
2079
+ path: context.path,
2080
+ raw: safeRawBody(context.body),
2081
+ response: context.response,
2082
+ retryAfter: parseRetryAfter(context.headers?.get("retry-after"), context.now)
2083
+ };
2084
+ if (parsed.code === "PHONE_VERIFICATION_REQUIRED") return new ItdPhoneVerificationError({
2085
+ ...init,
2086
+ userId: parsed.userId
2087
+ });
2088
+ return new (CODE_TO_CLASS[parsed.code] ?? (classByStatus(context.status)))(init);
2089
+ }
2090
+ //#endregion
2091
+ //#region src/core/execution/transport.ts
2092
+ /** Ставит заголовок, превращая ошибку среды в понятную ошибку конфигурации. */
2093
+ function setHeader(headers, name, value) {
2094
+ try {
2095
+ headers.set(name, value);
2096
+ } catch (cause) {
2097
+ throw new ItdConfigError(`Некорректный HTTP-заголовок ${JSON.stringify(name)}: проверьте его имя и значение.`, { cause });
2098
+ }
2099
+ }
2100
+ /** Тело, которое отправляется как есть, без сериализации в JSON. */
2101
+ function isRawBody(body) {
2102
+ if (typeof body !== "object" || body === null) return typeof body === "string";
2103
+ return typeof FormData !== "undefined" && body instanceof FormData || isBlob(body) || typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams || typeof ReadableStream !== "undefined" && body instanceof ReadableStream || body instanceof ArrayBuffer || ArrayBuffer.isView(body);
2104
+ }
2105
+ /**
2106
+ * Читает тело ответа один раз.
2107
+ *
2108
+ * Ответ можно прочитать только однократно, а тело нужно и при успехе, и при ошибке,
2109
+ * поэтому чтение происходит здесь, до ветвления по статусу.
2110
+ */
2111
+ async function readBody(response) {
2112
+ if (response.status === 204 || response.status === 205) return void 0;
2113
+ if (response.headers.get("content-length") === "0") return void 0;
2114
+ if ((response.headers.get("content-type") ?? "").includes("json")) {
2115
+ const text = await response.text();
2116
+ if (text === "") return void 0;
2117
+ try {
2118
+ return JSON.parse(text);
2119
+ } catch {
2120
+ return text;
2121
+ }
2122
+ }
2123
+ const text = await response.text();
2124
+ return text === "" ? void 0 : text;
2125
+ }
2126
+ /** Ошибка отмены в формате `fetch`. */
2127
+ function createAbortError() {
2128
+ const error = /* @__PURE__ */ new Error("Операция прервана");
2129
+ error.name = "AbortError";
2130
+ return error;
2131
+ }
2132
+ /** Прерывает ожидание промиса при срабатывании сигнала. */
2133
+ function abortable(promise, signal) {
2134
+ if (signal.aborted) {
2135
+ promise.catch(() => {});
2136
+ return Promise.reject(createAbortError());
2137
+ }
2138
+ let onAbort;
2139
+ const interrupted = new Promise((_resolve, reject) => {
2140
+ onAbort = () => reject(createAbortError());
2141
+ signal.addEventListener("abort", onAbort, { once: true });
2142
+ });
2143
+ return Promise.race([promise, interrupted]).finally(() => {
2144
+ if (onAbort) signal.removeEventListener("abort", onAbort);
2145
+ });
2146
+ }
2147
+ /**
2148
+ * Объединяет пользовательский `AbortSignal`, сигнал жизни клиента и таймаут.
2149
+ *
2150
+ * Реализовано вручную, а не через `AbortSignal.any`: последний появился только в Node 20,
2151
+ * а библиотека поддерживает Node 18. Причина отмены переносится от источника как есть.
2152
+ */
2153
+ function createAbortBundle(userSignal, lifetimeSignal, timeout, clock) {
2154
+ const controller = new AbortController();
2155
+ let timedOut = false;
2156
+ const link = (source) => {
2157
+ if (!source) return void 0;
2158
+ if (source.aborted) {
2159
+ controller.abort(source.reason);
2160
+ return;
2161
+ }
2162
+ const onAbort = () => controller.abort(source.reason);
2163
+ source.addEventListener("abort", onAbort, { once: true });
2164
+ return () => source.removeEventListener("abort", onAbort);
2165
+ };
2166
+ const unlink = [link(userSignal), link(lifetimeSignal)];
2167
+ const cancelTimer = timeout > 0 ? clock.schedule(() => {
2168
+ timedOut = true;
2169
+ controller.abort();
2170
+ }, timeout) : void 0;
2171
+ return {
2172
+ signal: controller.signal,
2173
+ timedOut: () => timedOut,
2174
+ cleanup: () => {
2175
+ cancelTimer?.();
2176
+ for (const detach of unlink) detach?.();
2177
+ }
2178
+ };
2179
+ }
2180
+ /**
2181
+ * Единственное место, откуда библиотека ходит в сеть.
2182
+ *
2183
+ * Отвечает за сборку URL, заголовки, cookie, таймауты, разбор ответа и превращение любой
2184
+ * неудачи в типизированную ошибку. Авторизация, повторы, очередь и плагины — отдельные
2185
+ * слои конвейера, и транспорт о них не знает.
2186
+ */
2187
+ var Transport = class {
2188
+ #config;
2189
+ #deps;
2190
+ constructor(config, deps) {
2191
+ this.#config = config;
2192
+ this.#deps = deps;
2193
+ }
2194
+ /** Базовый URL, к которому обращается транспорт. */
2195
+ get baseUrl() {
2196
+ return this.#config.baseUrl;
2197
+ }
2198
+ /**
2199
+ * Выполняет один сетевой запрос.
2200
+ *
2201
+ * @throws {ItdApiError} если сервер ответил статусом ≥ 400
2202
+ * @throws {ItdTimeoutError} если истёк таймаут
2203
+ * @throws {ItdAbortError} если запрос отменён через `signal`
2204
+ * @throws {ItdNetworkError} если запрос не дошёл до сервера
2205
+ */
2206
+ send = async (input) => {
2207
+ const request = identifyRequest(input);
2208
+ const method = request.method.toUpperCase();
2209
+ const url = this.buildUrl(request);
2210
+ const headers = await this.#buildHeaders(request, url);
2211
+ const attempt = request.attempt ?? 1;
2212
+ const timeout = request.timeout ?? this.#config.timeout;
2213
+ const abort = createAbortBundle(request.signal, this.#deps.lifetimeSignal, timeout, this.#config.clock);
2214
+ const startedAt = this.#config.clock.now();
2215
+ let cleanupBody;
2216
+ try {
2217
+ let body;
2218
+ try {
2219
+ const prepared = await this.#prepareBody(request, headers, abort.signal, attempt);
2220
+ body = prepared.body;
2221
+ cleanupBody = prepared.cleanup;
2222
+ } catch (error) {
2223
+ const failure = this.#toTransportError(error, abort, request, method, timeout);
2224
+ const context = {
2225
+ operationId: request.operationId,
2226
+ method,
2227
+ path: request.path,
2228
+ url,
2229
+ headers,
2230
+ attempt
2231
+ };
2232
+ await dispatchRequestHook(this.#config.hooks, "onError", {
2233
+ ...context,
2234
+ duration: this.#config.clock.now() - startedAt,
2235
+ error: failure
2236
+ });
2237
+ throw failure;
2238
+ }
2239
+ const context = {
2240
+ operationId: request.operationId,
2241
+ method,
2242
+ path: request.path,
2243
+ url,
2244
+ headers,
2245
+ attempt
2246
+ };
2247
+ await dispatchRequestHook(this.#config.hooks, "onRequest", context);
2248
+ this.#config.logger?.debug(`→ ${method} ${request.path}`, {
2249
+ headers: redactHeaders(headers),
2250
+ body: request.bodyFactory ? "[повторяемое тело]" : redactBody(request.body)
2251
+ });
2252
+ let response;
2253
+ try {
2254
+ const init = {
2255
+ method,
2256
+ headers,
2257
+ signal: abort.signal,
2258
+ ...body !== void 0 ? { body } : {},
2259
+ ...this.#config.sendCredentials ? { credentials: "include" } : {}
2260
+ };
2261
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) init.duplex = "half";
2262
+ response = await runAttemptInterceptors(request, {
2263
+ ...context,
2264
+ body,
2265
+ signal: abort.signal
2266
+ }, async () => {
2267
+ try {
2268
+ return await this.#config.fetch(url, init);
2269
+ } catch (error) {
2270
+ throw this.#toTransportError(error, abort, request, method, timeout);
2271
+ }
2272
+ });
2273
+ } catch (error) {
2274
+ const duration = this.#config.clock.now() - startedAt;
2275
+ await dispatchRequestHook(this.#config.hooks, "onError", {
2276
+ ...context,
2277
+ duration,
2278
+ error
2279
+ });
2280
+ this.#config.logger?.warn(`× ${method} ${request.path} (${duration} мс): ${error instanceof Error ? error.message : String(error)}`);
2281
+ throw error;
2282
+ }
2283
+ if (this.#deps.onRateLimit) {
2284
+ const { limit, remaining } = readRateLimit(response.headers);
2285
+ this.#deps.onRateLimit(limit, remaining, request);
2286
+ }
2287
+ if (this.#config.useCookieJar) this.#deps.cookies?.setFromResponse(url, response);
2288
+ if (response.ok && hasRequestHook(this.#config.hooks, "onResponse")) await dispatchRequestHook(this.#config.hooks, "onResponse", {
2289
+ ...context,
2290
+ status: response.status,
2291
+ duration: this.#config.clock.now() - startedAt,
2292
+ response: response.clone()
2293
+ });
2294
+ const payload = await this.#readBodyOrFail(response, context, request, method, abort, timeout);
2295
+ const duration = this.#config.clock.now() - startedAt;
2296
+ if (!response.ok) {
2297
+ const error = createApiError({
2298
+ method,
2299
+ path: request.path,
2300
+ status: response.status,
2301
+ now: this.#config.clock.now(),
2302
+ statusText: response.statusText,
2303
+ headers: response.headers,
2304
+ response,
2305
+ body: payload
2306
+ });
2307
+ await dispatchRequestHook(this.#config.hooks, "onError", {
2308
+ ...context,
2309
+ duration,
2310
+ error
2311
+ });
2312
+ this.#config.logger?.warn(`← ${response.status} ${method} ${request.path} (${duration} мс): ${error.message}`);
2313
+ throw error;
2314
+ }
2315
+ this.#config.logger?.debug(`← ${response.status} ${method} ${request.path} (${duration} мс)`);
2316
+ return request.raw ? payload : unwrapData(payload);
2317
+ } finally {
2318
+ try {
2319
+ await cleanupBody?.();
2320
+ } catch (error) {
2321
+ this.#config.logger?.warn(`не удалось закрыть тело ${method} ${request.path}`, error);
2322
+ } finally {
2323
+ abort.cleanup();
2324
+ }
2325
+ }
2326
+ };
2327
+ /** Подготавливает тело внутри попытки, чтобы поток можно было открыть заново при retry. */
2328
+ async #prepareBody(request, headers, signal, attempt) {
2329
+ if (request.bodyFactory) {
2330
+ if (request.body !== void 0 && request.body !== null) throw new ItdConfigError("body и bodyFactory нельзя задавать одновременно");
2331
+ const pending = Promise.resolve(request.bodyFactory({
2332
+ signal,
2333
+ attempt
2334
+ }));
2335
+ let prepared;
2336
+ try {
2337
+ prepared = await abortable(pending, signal);
2338
+ } catch (error) {
2339
+ if (signal.aborted) pending.then(async (late) => {
2340
+ try {
2341
+ await late.cleanup?.();
2342
+ } catch (cleanupError) {
2343
+ this.#config.logger?.warn(`не удалось закрыть отложенное тело ${request.method} ${request.path}`, cleanupError);
2344
+ }
2345
+ }).catch(() => {});
2346
+ throw error;
2347
+ }
2348
+ for (const [name, value] of Object.entries(prepared.headers ?? {})) if (!headers.has(name)) setHeader(headers, name, value);
2349
+ return {
2350
+ body: prepared.body,
2351
+ cleanup: prepared.cleanup
2352
+ };
2353
+ }
2354
+ if (request.body === void 0 || request.body === null) return {
2355
+ body: void 0,
2356
+ cleanup: void 0
2357
+ };
2358
+ if (isRawBody(request.body)) return {
2359
+ body: request.body,
2360
+ cleanup: void 0
2361
+ };
2362
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
2363
+ return {
2364
+ body: JSON.stringify(request.body),
2365
+ cleanup: void 0
2366
+ };
2367
+ }
2368
+ /** Читает тело и преобразует ошибку чтения в транспортную ошибку библиотеки. */
2369
+ async #readBodyOrFail(response, context, request, method, abort, timeout) {
2370
+ const startedAt = this.#config.clock.now();
2371
+ try {
2372
+ return await abortable(readBody(response), abort.signal);
2373
+ } catch (error) {
2374
+ await response.body?.cancel().catch(() => {});
2375
+ const failure = this.#toTransportError(error, abort, request, method, timeout);
2376
+ await dispatchRequestHook(this.#config.hooks, "onError", {
2377
+ ...context,
2378
+ duration: this.#config.clock.now() - startedAt,
2379
+ error: failure
2380
+ });
2381
+ this.#config.logger?.warn(`× ${method} ${request.path}: не удалось прочитать тело ответа — ${failure.message}`);
2382
+ throw failure;
2383
+ }
2384
+ }
2385
+ /**
2386
+ * Итоговый URL со строкой запроса. Нужен и слою повторов — для хука `onRetry`.
2387
+ *
2388
+ * Хост берётся из самого запроса, если он там задан: у сервисов платформы свои домены.
2389
+ */
2390
+ buildUrl(request) {
2391
+ const base = request.baseUrl ?? this.#config.baseUrl;
2392
+ return joinUrl(base, request.path) + buildQuery(request.query);
2393
+ }
2394
+ /**
2395
+ * Собирает общие заголовки клиента: `User-Agent`, идентификатор устройства,
2396
+ * заголовки конфигурации и cookie для указанного адреса.
2397
+ */
2398
+ async platformHeaders(url) {
2399
+ const headers = new Headers();
2400
+ headers.set("X-Requested-With", "XMLHttpRequest");
2401
+ if (this.#config.userAgent) setHeader(headers, "User-Agent", this.#config.userAgent);
2402
+ if (this.#deps.getDeviceId) setHeader(headers, "X-Device-Id", await this.#deps.getDeviceId());
2403
+ for (const [name, value] of Object.entries(this.#config.headers)) setHeader(headers, name, value);
2404
+ if (this.#config.useCookieJar && this.#deps.cookies) {
2405
+ const cookie = this.#deps.cookies.getHeader(url);
2406
+ if (cookie) setHeader(headers, "Cookie", cookie);
2407
+ }
2408
+ return headers;
2409
+ }
2410
+ /**
2411
+ * Дополняет общие заголовки значением `Accept`, заголовками конвейера и вызова.
2412
+ * Заголовки вызова применяются последними.
2413
+ */
2414
+ async #buildHeaders(request, url) {
2415
+ const headers = await this.platformHeaders(url);
2416
+ if (!headers.has("Accept")) headers.set("Accept", "application/json");
2417
+ for (const [name, value] of Object.entries(request.layerHeaders ?? {})) setHeader(headers, name, value);
2418
+ for (const [name, value] of Object.entries(request.headers ?? {})) setHeader(headers, name, value);
2419
+ return headers;
2420
+ }
2421
+ /** Превращает исключение `fetch` в понятную ошибку библиотеки. */
2422
+ #toTransportError(error, abort, request, method, timeout) {
2423
+ const aborted = abort.signal.aborted || error instanceof Error && error.name === "AbortError";
2424
+ if (aborted && abort.timedOut()) return new ItdTimeoutError({
2425
+ timeout,
2426
+ method,
2427
+ path: request.path
2428
+ });
2429
+ if (aborted) {
2430
+ const reason = abort.signal.reason;
2431
+ return new ItdAbortError(`Запрос ${method} ${request.path} отменён`, reason !== void 0 ? { cause: reason } : void 0);
2432
+ }
2433
+ if (error instanceof ItdError) return error;
2434
+ return new ItdNetworkError(`Не удалось выполнить ${method} ${request.path}: ${error instanceof Error ? error.message : String(error)}`, {
2435
+ method,
2436
+ path: request.path,
2437
+ cause: error
2438
+ });
2439
+ }
2440
+ };
2441
+ //#endregion
2442
+ //#region src/core/execution/client-runtime.ts
2443
+ /** Имена стадий основного request pipeline в порядке выполнения. @internal */
2444
+ const ClientRuntimeStage = Object.freeze({
2445
+ OperationPlugins: "operation_plugins",
2446
+ Services: "services",
2447
+ Retry: "retry",
2448
+ AuthRecovery: "auth_recovery",
2449
+ AuthPreparation: "auth_preparation",
2450
+ Queue: "queue",
2451
+ Attempt: "attempt",
2452
+ AuthHeaders: "auth_headers",
2453
+ Transport: "transport"
2454
+ });
2455
+ /** Регистрирует встроенные сервисы и накладывает пользовательские overrides. */
2456
+ function createServiceRegistry(config) {
2457
+ const services = new ServiceRegistry(config.baseUrl);
2458
+ const overrides = new Map(config.services.map((service) => [service.name.trim(), service]));
2459
+ for (const builtIn of BUILT_IN_SERVICES) {
2460
+ const override = overrides.get(builtIn.name);
2461
+ overrides.delete(builtIn.name);
2462
+ services.define(override ? mergeService(builtIn, override) : builtIn);
2463
+ }
2464
+ for (const service of overrides.values()) services.define(service);
2465
+ return services;
2466
+ }
2467
+ /** Собирает внутренний runtime клиента и единственный request pipeline. @internal */
2468
+ function createClientRuntime(options, internals) {
2469
+ const catalog = internals.catalog;
2470
+ const config = resolveRuntimeConfig(options, catalog);
2471
+ const jar = new CookieJar();
2472
+ const plugins = new PluginRegistry({
2473
+ shutdownTimeout: config.shutdownTimeout,
2474
+ clock: config.clock
2475
+ });
2476
+ const services = createServiceRegistry(config);
2477
+ const lifetime = new AbortController();
2478
+ const sharedQueues = config.rateLimit ? internals.queues : void 0;
2479
+ const queues = sharedQueues ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit, config.clock) : void 0);
2480
+ const ownsQueues = sharedQueues === void 0;
2481
+ let auth;
2482
+ let transport;
2483
+ /**
2484
+ * Бакет, из которого спишется запрос.
2485
+ *
2486
+ * Источники по убыванию приоритета: `rateLimitBucket` запроса, правило `rateLimit.bucket`,
2487
+ * каталог операций.
2488
+ */
2489
+ const bucketFor = (request) => {
2490
+ if (request.rateLimitBucket !== void 0) return request.rateLimitBucket;
2491
+ return config.rateLimit?.bucket?.({
2492
+ operationId: request.operationId,
2493
+ method: request.method,
2494
+ path: request.path
2495
+ }) ?? catalog.bucketOf(request.operationId);
2496
+ };
2497
+ const queueKeyFor = (request) => requestQueueKey(request, (target) => ({
2498
+ destination: originOf(transport.buildUrl(target)) || void 0,
2499
+ bucket: bucketFor(target)
2500
+ }));
2501
+ const queueFor = (request) => {
2502
+ if (!queues) return void 0;
2503
+ const key = queueKeyFor(request);
2504
+ return queues.for(key.destination, key.bucket);
2505
+ };
2506
+ /** Передаёт остаток из заголовков ответа бакету запроса; тот решает, тормозить ли себя. */
2507
+ const observeRateLimit = (limit, remaining, request) => {
2508
+ const queue = queueFor(request);
2509
+ if (!queue) return;
2510
+ const waited = queue.observe(limit, remaining);
2511
+ if (waited > 0) config.logger?.debug(`остаток лимита ${remaining} из ${limit ?? "?"}, бакет ${queue.bucket} ждёт ${waited} мс`);
2512
+ };
2513
+ transport = new Transport({
2514
+ ...config,
2515
+ hooks: config.hooks
2516
+ }, {
2517
+ onRateLimit: queues ? observeRateLimit : void 0,
2518
+ cookies: config.useCookieJar ? jar : void 0,
2519
+ getDeviceId: () => auth.deviceId(),
2520
+ lifetimeSignal: lifetime.signal
2521
+ });
2522
+ const stages = [
2523
+ {
2524
+ name: ClientRuntimeStage.OperationPlugins,
2525
+ middleware: createPluginsMiddleware(plugins)
2526
+ },
2527
+ {
2528
+ name: ClientRuntimeStage.Services,
2529
+ middleware: createServicesMiddleware(services)
2530
+ },
2531
+ {
2532
+ name: ClientRuntimeStage.Retry,
2533
+ middleware: createRetryMiddleware({
2534
+ clock: config.clock,
2535
+ catalog,
2536
+ retry: config.retry,
2537
+ rateLimitDelays: config.rateLimit?.retryDelays ?? [],
2538
+ pauseQueue: queues ? (ms, request) => queueFor(request)?.pause(ms) : void 0,
2539
+ hooks: config.hooks,
2540
+ logger: config.logger,
2541
+ buildUrl: (request) => transport.buildUrl(request)
2542
+ })
2543
+ },
2544
+ {
2545
+ name: ClientRuntimeStage.AuthRecovery,
2546
+ middleware: createAuthRecoveryMiddleware({ recover: () => auth.recover() })
2547
+ },
2548
+ {
2549
+ name: ClientRuntimeStage.AuthPreparation,
2550
+ middleware: createAuthPreparationMiddleware({ prepare: () => auth.prepare() })
2551
+ }
2552
+ ];
2553
+ if (queues) stages.push({
2554
+ name: ClientRuntimeStage.Queue,
2555
+ middleware: createQueueMiddleware((request, task) => {
2556
+ const queue = queueFor(request);
2557
+ return queue ? queue.schedule(task, request.signal) : task();
2558
+ })
2559
+ });
2560
+ stages.push({
2561
+ name: ClientRuntimeStage.Attempt,
2562
+ middleware: createAttemptMiddleware()
2563
+ });
2564
+ stages.push({
2565
+ name: ClientRuntimeStage.AuthHeaders,
2566
+ middleware: createAuthHeadersMiddleware({ currentHeaders: () => auth.currentHeaders() })
2567
+ });
2568
+ const handler = composePipeline(stages.map(({ middleware }) => middleware), transport.send);
2569
+ const clientHandler = (request) => {
2570
+ try {
2571
+ if (!isDisposeCleanupRequest(request)) internals.assertActive?.("выполнить новый запрос");
2572
+ if (request.rateLimitBucket !== void 0) assertKnownBucket(request.rateLimitBucket, "rateLimitBucket", config.rateLimit?.bucket, catalog);
2573
+ } catch (error) {
2574
+ return Promise.reject(error);
2575
+ }
2576
+ return handler(request);
2577
+ };
2578
+ auth = internals.auth({
2579
+ config,
2580
+ handler: clientHandler,
2581
+ cookies: jar
2582
+ });
2583
+ const http = new HttpClient({
2584
+ handler: clientHandler,
2585
+ baseUrl: config.baseUrl,
2586
+ catalog
2587
+ });
2588
+ const stageOrder = Object.freeze([...stages.map(({ name }) => name), ClientRuntimeStage.Transport]);
2589
+ return {
2590
+ config,
2591
+ http,
2592
+ auth,
2593
+ cookies: jar,
2594
+ plugins,
2595
+ services,
2596
+ stageOrder,
2597
+ platformHeaders: (url) => transport.platformHeaders(url),
2598
+ rateLimitState: () => queues?.states() ?? [],
2599
+ close: () => {
2600
+ if (ownsQueues) queues?.stop();
2601
+ },
2602
+ dispose: async () => {
2603
+ lifetime.abort(new ItdAbortError("Клиент освобождён через dispose(), запрос отменён"));
2604
+ if (ownsQueues) queues?.clear();
2605
+ auth.dispose();
2606
+ await plugins.dispose();
2607
+ }
2608
+ };
2609
+ }
2610
+ //#endregion
2611
+ //#region src/domain/buckets.ts
2612
+ /**
2613
+ * Ёмкость серверных счётчиков частоты, запросов в минуту.
2614
+ *
2615
+ * Таблица действует до первого ответа бакета; дальше ёмкость берётся из заголовка
2616
+ * `x-ratelimit-limit` и заменяет табличную. `default` — счётчик любого пути без
2617
+ * собственного правила на сервере.
2618
+ */
2619
+ const BUCKET_LIMITS = Object.freeze({
2620
+ "posts.stats": 180,
2621
+ default: 150,
2622
+ feed: 90,
2623
+ "posts.like": 85,
2624
+ "posts.comments": 80,
2625
+ hashtags: 50,
2626
+ users: 40,
2627
+ notifications: 40,
2628
+ "files.get": 40,
2629
+ auth: 35,
2630
+ "auth.refresh": 25,
2631
+ search: 25,
2632
+ "comments.like": 22,
2633
+ "files.upload": 15,
2634
+ "files.remove": 15,
2635
+ "posts.comment": 14,
2636
+ "hashtags.trending": 13,
2637
+ "posts.repost": 7,
2638
+ "users.follow": 7,
2639
+ "verification.status": 6,
2640
+ "posts.create": 5,
2641
+ "users.updateMe": 3,
2642
+ "reports.create": 3,
2643
+ "verification.submit": 3
2644
+ });
2645
+ /** Счётчик, из которого списывается путь без собственного правила на сервере. */
2646
+ const DEFAULT_RATE_LIMIT_BUCKET = "default";
2647
+ /** Известно ли библиотеке имя бакета. */
2648
+ function isKnownBucket(name) {
2649
+ return Object.hasOwn(BUCKET_LIMITS, name);
2650
+ }
2651
+ /**
2652
+ * Встроенные поправки бакетов.
2653
+ *
2654
+ * Таймаут загрузки файла — пять минут против обычных тридцати секунд, поэтому её
2655
+ * одновременность ограничена одним запросом.
2656
+ */
2657
+ const DEFAULT_BUCKET_OVERRIDES = Object.freeze({ "files.upload": Object.freeze({ concurrency: 1 }) });
2658
+ //#endregion
2659
+ //#region src/domain/operations.ts
2660
+ function freezeOperations(operations) {
2661
+ for (const definition of Object.values(operations)) Object.freeze(definition);
2662
+ return Object.freeze(operations);
2663
+ }
2664
+ /**
2665
+ * Каталог встроенных операций.
2666
+ *
2667
+ * ID описывает смысл вызова и не меняется при переносе HTTP-пути. Method и retrySafety
2668
+ * хранятся здесь, чтобы resources, retry и плагины не вели независимые таблицы операций.
2669
+ */
2670
+ const OPERATIONS = freezeOperations({
2671
+ "auth.check": {
2672
+ method: "GET",
2673
+ retrySafety: RetrySafety.Safe
2674
+ },
2675
+ "auth.signUp": {
2676
+ method: "POST",
2677
+ retrySafety: RetrySafety.Unsafe,
2678
+ bucket: "auth"
2679
+ },
2680
+ "auth.signIn": {
2681
+ method: "POST",
2682
+ retrySafety: RetrySafety.Safe,
2683
+ bucket: "auth"
2684
+ },
2685
+ "auth.verifyOtp": {
2686
+ method: "POST",
2687
+ retrySafety: RetrySafety.Unsafe,
2688
+ bucket: "auth"
2689
+ },
2690
+ "auth.resendOtp": {
2691
+ method: "POST",
2692
+ retrySafety: RetrySafety.Unsafe,
2693
+ bucket: "auth"
2694
+ },
2695
+ "auth.refresh": {
2696
+ method: "POST",
2697
+ retrySafety: RetrySafety.Unsafe,
2698
+ bucket: "auth.refresh"
2699
+ },
2700
+ "auth.logout": {
2701
+ method: "POST",
2702
+ retrySafety: RetrySafety.Unsafe,
2703
+ bucket: "auth"
2704
+ },
2705
+ "auth.forgotPassword": {
2706
+ method: "POST",
2707
+ retrySafety: RetrySafety.Unsafe,
2708
+ bucket: "auth"
2709
+ },
2710
+ "auth.resetPassword": {
2711
+ method: "POST",
2712
+ retrySafety: RetrySafety.Unsafe,
2713
+ bucket: "auth"
2714
+ },
2715
+ "auth.changePassword": {
2716
+ method: "POST",
2717
+ retrySafety: RetrySafety.Unsafe,
2718
+ bucket: "auth"
2719
+ },
2720
+ "auth.sessions": {
2721
+ method: "GET",
2722
+ retrySafety: RetrySafety.Safe,
2723
+ bucket: "auth"
2724
+ },
2725
+ "auth.revokeSession": {
2726
+ method: "DELETE",
2727
+ retrySafety: RetrySafety.Unsafe,
2728
+ bucket: "auth"
2729
+ },
2730
+ "auth.revokeOtherSessions": {
2731
+ method: "DELETE",
2732
+ retrySafety: RetrySafety.Unsafe,
2733
+ bucket: "auth"
2734
+ },
2735
+ "users.me": {
2736
+ method: "GET",
2737
+ retrySafety: RetrySafety.Safe,
2738
+ bucket: "users"
2739
+ },
2740
+ "users.updateMe": {
2741
+ method: "PUT",
2742
+ retrySafety: RetrySafety.Idempotent,
2743
+ bucket: "users.updateMe"
2744
+ },
2745
+ "users.deactivate": {
2746
+ method: "DELETE",
2747
+ retrySafety: RetrySafety.Unsafe
2748
+ },
2749
+ "users.restore": {
2750
+ method: "POST",
2751
+ retrySafety: RetrySafety.Unsafe
2752
+ },
2753
+ "users.createProfile": {
2754
+ method: "POST",
2755
+ retrySafety: RetrySafety.Unsafe
2756
+ },
2757
+ "users.get": {
2758
+ method: "GET",
2759
+ retrySafety: RetrySafety.Safe,
2760
+ bucket: "users"
2761
+ },
2762
+ "users.checkUsername": {
2763
+ method: "GET",
2764
+ retrySafety: RetrySafety.Safe,
2765
+ bucket: "users"
2766
+ },
2767
+ "users.search": {
2768
+ method: "GET",
2769
+ retrySafety: RetrySafety.Safe,
2770
+ bucket: "users"
2771
+ },
2772
+ "users.whoToFollow": {
2773
+ method: "GET",
2774
+ retrySafety: RetrySafety.Safe,
2775
+ bucket: "users"
2776
+ },
2777
+ "users.topClans": {
2778
+ method: "GET",
2779
+ retrySafety: RetrySafety.Safe,
2780
+ bucket: "users"
2781
+ },
2782
+ "users.follow": {
2783
+ method: "POST",
2784
+ retrySafety: RetrySafety.Unsafe,
2785
+ bucket: "users.follow"
2786
+ },
2787
+ "users.unfollow": {
2788
+ method: "DELETE",
2789
+ retrySafety: RetrySafety.Unsafe,
2790
+ bucket: "users.follow"
2791
+ },
2792
+ "users.followers": {
2793
+ method: "GET",
2794
+ retrySafety: RetrySafety.Safe,
2795
+ bucket: "users"
2796
+ },
2797
+ "users.following": {
2798
+ method: "GET",
2799
+ retrySafety: RetrySafety.Safe,
2800
+ bucket: "users"
2801
+ },
2802
+ "users.followStatus": {
2803
+ method: "POST",
2804
+ retrySafety: RetrySafety.Safe
2805
+ },
2806
+ "users.block": {
2807
+ method: "POST",
2808
+ retrySafety: RetrySafety.Unsafe
2809
+ },
2810
+ "users.unblock": {
2811
+ method: "DELETE",
2812
+ retrySafety: RetrySafety.Unsafe
2813
+ },
2814
+ "users.blocked": {
2815
+ method: "GET",
2816
+ retrySafety: RetrySafety.Safe,
2817
+ bucket: "users"
2818
+ },
2819
+ "users.getPrivacy": {
2820
+ method: "GET",
2821
+ retrySafety: RetrySafety.Safe,
2822
+ bucket: "users"
2823
+ },
2824
+ "users.updatePrivacy": {
2825
+ method: "PUT",
2826
+ retrySafety: RetrySafety.Idempotent
2827
+ },
2828
+ "users.pins": {
2829
+ method: "GET",
2830
+ retrySafety: RetrySafety.Safe,
2831
+ bucket: "users"
2832
+ },
2833
+ "users.setPin": {
2834
+ method: "PUT",
2835
+ retrySafety: RetrySafety.Idempotent
2836
+ },
2837
+ "users.removePin": {
2838
+ method: "DELETE",
2839
+ retrySafety: RetrySafety.Unsafe
2840
+ },
2841
+ "posts.list": {
2842
+ method: "GET",
2843
+ retrySafety: RetrySafety.Safe,
2844
+ bucket: "feed"
2845
+ },
2846
+ "posts.create": {
2847
+ method: "POST",
2848
+ retrySafety: RetrySafety.Unsafe,
2849
+ bucket: "posts.create"
2850
+ },
2851
+ "posts.get": {
2852
+ method: "GET",
2853
+ retrySafety: RetrySafety.Safe
2854
+ },
2855
+ "posts.update": {
2856
+ method: "PUT",
2857
+ retrySafety: RetrySafety.Idempotent
2858
+ },
2859
+ "posts.remove": {
2860
+ method: "DELETE",
2861
+ retrySafety: RetrySafety.Unsafe
2862
+ },
2863
+ "posts.restore": {
2864
+ method: "POST",
2865
+ retrySafety: RetrySafety.Unsafe
2866
+ },
2867
+ "posts.like": {
2868
+ method: "POST",
2869
+ retrySafety: RetrySafety.Unsafe,
2870
+ bucket: "posts.like"
2871
+ },
2872
+ "posts.unlike": {
2873
+ method: "DELETE",
2874
+ retrySafety: RetrySafety.Unsafe,
2875
+ bucket: "posts.like"
2876
+ },
2877
+ "posts.repost": {
2878
+ method: "POST",
2879
+ retrySafety: RetrySafety.Unsafe,
2880
+ bucket: "posts.repost"
2881
+ },
2882
+ "posts.unrepost": {
2883
+ method: "DELETE",
2884
+ retrySafety: RetrySafety.Unsafe,
2885
+ bucket: "posts.repost"
2886
+ },
2887
+ "posts.pin": {
2888
+ method: "POST",
2889
+ retrySafety: RetrySafety.Unsafe
2890
+ },
2891
+ "posts.unpin": {
2892
+ method: "DELETE",
2893
+ retrySafety: RetrySafety.Unsafe
2894
+ },
2895
+ "posts.vote": {
2896
+ method: "POST",
2897
+ retrySafety: RetrySafety.Unsafe
2898
+ },
2899
+ "posts.stats": {
2900
+ method: "POST",
2901
+ retrySafety: RetrySafety.Safe,
2902
+ bucket: "posts.stats"
2903
+ },
2904
+ "posts.byUser": {
2905
+ method: "GET",
2906
+ retrySafety: RetrySafety.Safe
2907
+ },
2908
+ "posts.likedByUser": {
2909
+ method: "GET",
2910
+ retrySafety: RetrySafety.Safe
2911
+ },
2912
+ "posts.comments": {
2913
+ method: "GET",
2914
+ retrySafety: RetrySafety.Safe,
2915
+ bucket: "posts.comments"
2916
+ },
2917
+ "posts.comment": {
2918
+ method: "POST",
2919
+ retrySafety: RetrySafety.Unsafe,
2920
+ bucket: "posts.comment"
2921
+ },
2922
+ "comments.replies": {
2923
+ method: "GET",
2924
+ retrySafety: RetrySafety.Safe
2925
+ },
2926
+ "comments.reply": {
2927
+ method: "POST",
2928
+ retrySafety: RetrySafety.Unsafe
2929
+ },
2930
+ "comments.update": {
2931
+ method: "PATCH",
2932
+ retrySafety: RetrySafety.Idempotent
2933
+ },
2934
+ "comments.remove": {
2935
+ method: "DELETE",
2936
+ retrySafety: RetrySafety.Unsafe
2937
+ },
2938
+ "comments.restore": {
2939
+ method: "POST",
2940
+ retrySafety: RetrySafety.Unsafe
2941
+ },
2942
+ "comments.like": {
2943
+ method: "POST",
2944
+ retrySafety: RetrySafety.Unsafe,
2945
+ bucket: "comments.like"
2946
+ },
2947
+ "comments.unlike": {
2948
+ method: "DELETE",
2949
+ retrySafety: RetrySafety.Unsafe,
2950
+ bucket: "comments.like"
2951
+ },
2952
+ "files.upload": {
2953
+ method: "POST",
2954
+ retrySafety: RetrySafety.Unsafe,
2955
+ bucket: "files.upload"
2956
+ },
2957
+ "files.get": {
2958
+ method: "GET",
2959
+ retrySafety: RetrySafety.Safe,
2960
+ bucket: "files.get"
2961
+ },
2962
+ "files.remove": {
2963
+ method: "DELETE",
2964
+ retrySafety: RetrySafety.Unsafe,
2965
+ bucket: "files.remove"
2966
+ },
2967
+ "notifications.list": {
2968
+ method: "GET",
2969
+ retrySafety: RetrySafety.Safe,
2970
+ bucket: "notifications"
2971
+ },
2972
+ "notifications.count": {
2973
+ method: "GET",
2974
+ retrySafety: RetrySafety.Safe,
2975
+ bucket: "notifications"
2976
+ },
2977
+ "notifications.markRead": {
2978
+ method: "POST",
2979
+ retrySafety: RetrySafety.Idempotent
2980
+ },
2981
+ "notifications.markReadBatch": {
2982
+ method: "POST",
2983
+ retrySafety: RetrySafety.Idempotent
2984
+ },
2985
+ "notifications.markAllRead": {
2986
+ method: "POST",
2987
+ retrySafety: RetrySafety.Idempotent
2988
+ },
2989
+ "notifications.getSettings": {
2990
+ method: "GET",
2991
+ retrySafety: RetrySafety.Safe,
2992
+ bucket: "notifications"
2993
+ },
2994
+ "notifications.updateSettings": {
2995
+ method: "PUT",
2996
+ retrySafety: RetrySafety.Idempotent
2997
+ },
2998
+ "realtime.poll.updates": {
2999
+ method: "GET",
3000
+ retrySafety: RetrySafety.Safe,
3001
+ bucket: "notifications"
3002
+ },
3003
+ "realtime.poll.unread": {
3004
+ method: "GET",
3005
+ retrySafety: RetrySafety.Safe,
3006
+ bucket: "notifications"
3007
+ },
3008
+ "hashtags.search": {
3009
+ method: "GET",
3010
+ retrySafety: RetrySafety.Safe,
3011
+ bucket: "hashtags"
3012
+ },
3013
+ "hashtags.trending": {
3014
+ method: "GET",
3015
+ retrySafety: RetrySafety.Safe,
3016
+ bucket: "hashtags.trending"
3017
+ },
3018
+ "hashtags.posts": {
3019
+ method: "GET",
3020
+ retrySafety: RetrySafety.Safe,
3021
+ bucket: "hashtags"
3022
+ },
3023
+ "search.all": {
3024
+ method: "GET",
3025
+ retrySafety: RetrySafety.Safe,
3026
+ bucket: "search"
3027
+ },
3028
+ "reports.create": {
3029
+ method: "POST",
3030
+ retrySafety: RetrySafety.Unsafe,
3031
+ bucket: "reports.create"
3032
+ },
3033
+ "subscription.status": {
3034
+ method: "GET",
3035
+ retrySafety: RetrySafety.Safe
3036
+ },
3037
+ "subscription.pay": {
3038
+ method: "POST",
3039
+ retrySafety: RetrySafety.Unsafe
3040
+ },
3041
+ "subscription.setAutoRenewal": {
3042
+ method: "POST",
3043
+ retrySafety: RetrySafety.Idempotent
3044
+ },
3045
+ "subscription.bindCard": {
3046
+ method: "POST",
3047
+ retrySafety: RetrySafety.Unsafe
3048
+ },
3049
+ "subscription.methods": {
3050
+ method: "GET",
3051
+ retrySafety: RetrySafety.Safe
3052
+ },
3053
+ "subscription.setDefaultMethod": {
3054
+ method: "POST",
3055
+ retrySafety: RetrySafety.Idempotent
3056
+ },
3057
+ "subscription.removeMethod": {
3058
+ method: "DELETE",
3059
+ retrySafety: RetrySafety.Unsafe
3060
+ },
3061
+ "verification.status": {
3062
+ method: "GET",
3063
+ retrySafety: RetrySafety.Safe,
3064
+ bucket: "verification.status"
3065
+ },
3066
+ "verification.submit": {
3067
+ method: "POST",
3068
+ retrySafety: RetrySafety.Unsafe,
3069
+ bucket: "verification.submit"
3070
+ },
3071
+ "platform.version": {
3072
+ method: "GET",
3073
+ retrySafety: RetrySafety.Safe
3074
+ },
3075
+ "platform.changelog": {
3076
+ method: "GET",
3077
+ retrySafety: RetrySafety.Safe
3078
+ },
3079
+ "platform.announcements": {
3080
+ method: "GET",
3081
+ retrySafety: RetrySafety.Safe
3082
+ },
3083
+ "platform.portal": {
3084
+ method: "GET",
3085
+ retrySafety: RetrySafety.Safe
3086
+ },
3087
+ "platform.status": {
3088
+ method: "GET",
3089
+ retrySafety: RetrySafety.Safe
3090
+ },
3091
+ "telemetry.dwell": {
3092
+ method: "POST",
3093
+ retrySafety: RetrySafety.Unsafe
3094
+ },
3095
+ "telemetry.interaction": {
3096
+ method: "POST",
3097
+ retrySafety: RetrySafety.Unsafe
3098
+ }
3099
+ });
3100
+ const BUILT_IN_IDS = new Set(Object.keys(OPERATIONS));
3101
+ /** Проверяет принадлежность ID встроенному каталогу. */
3102
+ function isBuiltInOperationId(value) {
3103
+ return BUILT_IN_IDS.has(value);
3104
+ }
3105
+ /** HTTP-метод встроенной операции. */
3106
+ function operationMethod(id) {
3107
+ return OPERATIONS[id].method;
3108
+ }
3109
+ /** Политика автоматического повтора встроенной операции. */
3110
+ function operationRetrySafety(id) {
3111
+ return OPERATIONS[id].retrySafety;
3112
+ }
3113
+ /**
3114
+ * Бакет операции.
3115
+ *
3116
+ * `raw` и `custom:*` попадают в `default`; назвать бакет явно позволяет
3117
+ * `rateLimitBucket` у запроса.
3118
+ */
3119
+ function operationBucket(id) {
3120
+ if (!isBuiltInOperationId(id)) return DEFAULT_RATE_LIMIT_BUCKET;
3121
+ return OPERATIONS[id].bucket ?? "default";
3122
+ }
3123
+ //#endregion
3124
+ //#region src/domain/catalog.ts
3125
+ /**
3126
+ * Каталог операций итд.com, которым пользуется ядро.
3127
+ *
3128
+ * Единственное место, где generic request executor встречается с таблицами конкретного API.
3129
+ * Собирается из уже существующих функций каталога: собственной логики здесь нет.
3130
+ *
3131
+ * @internal
3132
+ */
3133
+ const ITD_CATALOG = Object.freeze({
3134
+ retrySafetyOf: (id) => isBuiltInOperationId(id) ? operationRetrySafety(id) : void 0,
3135
+ methodOf: (id) => isBuiltInOperationId(id) ? operationMethod(id) : void 0,
3136
+ bucketOf: (id) => operationBucket(id),
3137
+ isKnownBucket,
3138
+ bucketLimits: BUCKET_LIMITS,
3139
+ bucketOverrides: DEFAULT_BUCKET_OVERRIDES,
3140
+ defaultBucket: DEFAULT_RATE_LIMIT_BUCKET
3141
+ });
3142
+ //#endregion
3143
+ //#region src/types/enums.ts
3144
+ /**
3145
+ * Вкладка ленты `GET /api/posts`.
3146
+ *
3147
+ * Множество закрытое: неизвестное значение сервер отвергнет.
3148
+ */
3149
+ const FeedTab = Object.freeze({
3150
+ /** Популярное. Курсор здесь — номер страницы в виде строки (`"2"`, `"6"`…). */
3151
+ Popular: "popular",
3152
+ /** Записи тех, на кого вы подписаны. Курсор — отметка времени последнего поста. */
3153
+ Following: "following",
3154
+ /** Лента клана. Курсор, как и в подписках, — отметка времени. */
3155
+ Clan: "clan"
3156
+ });
3157
+ /** Порядок комментариев к посту. */
3158
+ const CommentSort = Object.freeze({
3159
+ /** Сначала новые. */
3160
+ Newest: "newest",
3161
+ /** Сначала старые. */
3162
+ Oldest: "oldest",
3163
+ /** Сначала популярные. */
3164
+ Popular: "popular"
3165
+ });
3166
+ /** Тип вложения. */
3167
+ const AttachmentType = Object.freeze({
3168
+ Image: "image",
3169
+ Video: "video",
3170
+ /** Голосовые комментарии: `audio/ogg`, с полем `duration`. */
3171
+ Audio: "audio"
3172
+ });
3173
+ /**
3174
+ * Тип фрагмента разметки в тексте поста или комментария.
3175
+ *
3176
+ * Первые два сервер расставляет сам при разборе текста, остальные приходят от редактора.
3177
+ * Тип открытый: набор может пополниться.
3178
+ *
3179
+ * @example
3180
+ * ```ts
3181
+ * await itd.posts.update(postId, {
3182
+ * content: 'жирное слово',
3183
+ * spans: [{ type: SpanType.Bold, offset: 0, length: 6 }],
3184
+ * });
3185
+ * ```
3186
+ */
3187
+ const SpanType = Object.freeze({
3188
+ /** Хэштег. Название без решётки лежит в `tag`. */
3189
+ Hashtag: "hashtag",
3190
+ /** Упоминание. Имя пользователя лежит в `tag`. */
3191
+ Mention: "mention",
3192
+ /** Ссылка. Адрес лежит в `url`, а не в `tag`. */
3193
+ Link: "link",
3194
+ Bold: "bold",
3195
+ Italic: "italic",
3196
+ Underline: "underline",
3197
+ /** Зачёркнутый. */
3198
+ Strike: "strike",
3199
+ /** Спойлер: текст скрыт до нажатия. */
3200
+ Spoiler: "spoiler",
3201
+ /** Моноширинный. */
3202
+ Monospace: "monospace",
3203
+ Quote: "quote"
3204
+ });
3205
+ /** На что подаётся жалоба. */
3206
+ const ReportTargetType = Object.freeze({
3207
+ Post: "post",
3208
+ Comment: "comment",
3209
+ User: "user"
3210
+ });
3211
+ /** Причина жалобы. Множество закрытое. */
3212
+ const ReportReason = Object.freeze({
3213
+ Spam: "spam",
3214
+ Violence: "violence",
3215
+ Hate: "hate",
3216
+ Adult: "adult",
3217
+ Fraud: "fraud",
3218
+ Other: "other"
3219
+ });
3220
+ /** Состояние realtime-соединения. */
3221
+ const RealtimeStatus = Object.freeze({
3222
+ Connecting: "connecting",
3223
+ Connected: "connected",
3224
+ Error: "error",
3225
+ Disconnected: "disconnected"
3226
+ });
3227
+ /** Состояние сервиса платформы. Тип открытый. */
3228
+ const ServiceState = Object.freeze({
3229
+ /** Работает штатно. */
3230
+ Operational: "operational",
3231
+ /** Работает с деградацией. */
3232
+ Degraded: "degraded",
3233
+ /** Недоступен. */
3234
+ Downtime: "downtime"
3235
+ });
3236
+ /** Вид происшествия в истории сервиса. Тип открытый. */
3237
+ const IncidentKind = Object.freeze({
3238
+ /** Недоступен. */
3239
+ Down: "down",
3240
+ /** Деградация. */
3241
+ Degraded: "deg"
3242
+ });
3243
+ /**
3244
+ * Уровень доступа к разделу профиля.
3245
+ *
3246
+ * Общий набор значений для полей `wallAccess` и `likesVisibility` настроек приватности.
3247
+ * Тип открытый: сервер может прислать значение вне этого перечня.
3248
+ */
3249
+ const AccessType = Object.freeze({
3250
+ /** Никто. */
3251
+ Nobody: "nobody",
3252
+ /** Только взаимные подписки. */
3253
+ Mutual: "mutual",
3254
+ /** Подписчики. */
3255
+ Followers: "followers",
3256
+ /** Все. */
3257
+ Everyone: "everyone"
3258
+ });
3259
+ /** Кто может писать на стену профиля. Псевдоним {@link AccessType}. */
3260
+ const WallAccess = AccessType;
3261
+ /** Кто видит реакции пользователя. Псевдоним {@link AccessType}. */
3262
+ const LikesVisibility = AccessType;
3263
+ /**
3264
+ * Канонический тип уведомления (новое поколение имён).
3265
+ *
3266
+ * REST-эндпоинт `/api/notifications/` отдаёт старые имена (`like`, `comment`, `reply`,
3267
+ * `repost`, `mention`), SSE-поток — новые. Библиотека приводит их к этому набору,
3268
+ * сохраняя исходное значение в поле `rawType`.
3269
+ */
3270
+ const NotificationType = Object.freeze({
3271
+ /** Реакция на пост. Старое имя — `like`. */
3272
+ PostReaction: "post_reaction",
3273
+ /** Комментарий к посту. Старое имя — `comment`. */
3274
+ PostComment: "post_comment",
3275
+ /** Ответ на комментарий. Старое имя — `reply`. */
3276
+ CommentReply: "comment_reply",
3277
+ /** Репост. Старое имя — `repost`. */
3278
+ PostRepost: "post_repost",
3279
+ /** Упоминание в посте. Старое имя — `mention`. */
3280
+ PostMention: "post_mention",
3281
+ /** Реакция на комментарий. */
3282
+ CommentReaction: "comment_reaction",
3283
+ /** Упоминание в комментарии. */
3284
+ CommentMention: "comment_mention",
3285
+ /** Запись на вашей стене. */
3286
+ WallPost: "wall_post",
3287
+ /** На вас подписались. */
3288
+ Follow: "follow",
3289
+ /** Заявка на подписку (закрытый профиль). */
3290
+ FollowRequest: "follow_request",
3291
+ /** Заявка на подписку принята. */
3292
+ FollowAccepted: "follow_accepted",
3293
+ /** Верификация одобрена. Приходит только по REST. */
3294
+ VerificationApproved: "verification_approved",
3295
+ /** Верификация отклонена. Приходит только по REST. */
3296
+ VerificationRejected: "verification_rejected"
3297
+ });
3298
+ /**
3299
+ * Тип взаимодействия с контентом в телеметрии (`POST /api/v1/x`, поле `t`).
3300
+ *
3301
+ * Кодируется числом.
3302
+ */
3303
+ const InteractionType = Object.freeze({
3304
+ /** Открытие фотографии. */
3305
+ PhotoOpen: 1,
3306
+ /** Прогресс просмотра видео. Несёт поля `pm`/`dm`. */
3307
+ VideoProgress: 2
3308
+ });
3309
+ /**
3310
+ * Источник показа поста в телеметрии (поле `s`).
3311
+ *
3312
+ * Кодируется числом. Поле применимо к источникам `PostPage` и `Link`; для лент источник
3313
+ * передаётся контекстом `sc`.
3314
+ */
3315
+ const ViewSource = Object.freeze({
3316
+ FeedGlobal: 1,
3317
+ FeedFollowing: 2,
3318
+ FeedClan: 3,
3319
+ Profile: 4,
3320
+ Hashtag: 5,
3321
+ PostPage: 6,
3322
+ Link: 7,
3323
+ Search: 8
3324
+ });
3325
+ /**
3326
+ * Причина завершения просмотра поста в телеметрии (`POST /api/v1/i`, поле `r`).
3327
+ *
3328
+ * Кодируется числом.
3329
+ */
3330
+ const ViewReason = Object.freeze({
3331
+ /** Пост ушёл из зоны видимости при обычной прокрутке. */
3332
+ Normal: 0,
3333
+ /** Потеря фокуса окна. */
3334
+ Blur: 1,
3335
+ /** Вкладка скрыта. */
3336
+ Hidden: 2,
3337
+ /** Уход со страницы (`pagehide`). */
3338
+ PageHide: 3,
3339
+ /** Элемент перестал наблюдаться. */
3340
+ Unobserve: 4,
3341
+ /** Достигнут порог времени просмотра. */
3342
+ ThresholdMet: 5
3343
+ });
3344
+ /**
3345
+ * Строковые коды ошибок из поля `code`.
3346
+ *
3347
+ * Ключи намеренно повторяют написание сервера: код из ответа API можно найти здесь
3348
+ * поиском один в один, без мысленного перевода регистра.
3349
+ *
3350
+ * Список открыт — сервер может добавить новый код, и это не должно ломать типизацию.
3351
+ *
3352
+ * @example
3353
+ * ```ts
3354
+ * if (err.hasCode(ItdErrorCode.OTP_INVALID)) await restartOtpFlow();
3355
+ * ```
3356
+ */
3357
+ const ItdErrorCode = Object.freeze({
3358
+ BAD_REQUEST: "BAD_REQUEST",
3359
+ UNAUTHORIZED: "UNAUTHORIZED",
3360
+ ACCESS_DENIED: "ACCESS_DENIED",
3361
+ ENTITY_NOT_FOUND: "ENTITY_NOT_FOUND",
3362
+ ENTITY_ALREADY_EXISTS: "ENTITY_ALREADY_EXISTS",
3363
+ VALIDATION_ERROR: "VALIDATION_ERROR",
3364
+ BUSINESS_RULE_VIOLATION: "BUSINESS_RULE_VIOLATION",
3365
+ RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
3366
+ UNKNOWN_ERROR: "UNKNOWN_ERROR",
3367
+ /** Сервер отвечает так на `404`, `ENTITY_NOT_FOUND` в этом случае не приходит. */
3368
+ NOT_FOUND: "NOT_FOUND",
3369
+ /** На практике не приходит: вместо него сервер шлёт `TURNSTILE_VERIFICATION_FAILED`. */
3370
+ CAPTCHA_FAILED: "CAPTCHA_FAILED",
3371
+ /** Капча не пройдена: токен Turnstile недействителен, просрочен или уже использован. */
3372
+ TURNSTILE_VERIFICATION_FAILED: "TURNSTILE_VERIFICATION_FAILED",
3373
+ OTP_INVALID: "OTP_INVALID",
3374
+ /** `flowToken` неизвестен или просрочен — поток подтверждения нужно начинать заново. */
3375
+ INVALID_FLOW_TOKEN: "INVALID_FLOW_TOKEN",
3376
+ ACCOUNT_DEACTIVATED: "ACCOUNT_DEACTIVATED",
3377
+ ACCOUNT_EMAIL_DOMAIN_NOT_ALLOWED: "ACCOUNT_EMAIL_DOMAIN_NOT_ALLOWED",
3378
+ ACCOUNT_INVALID_CREDENTIALS: "ACCOUNT_INVALID_CREDENTIALS",
3379
+ ACCOUNT_TEMPORARILY_LOCKED: "ACCOUNT_TEMPORARILY_LOCKED",
3380
+ ACCOUNT_CURRENT_PASSWORD_INCORRECT: "ACCOUNT_CURRENT_PASSWORD_INCORRECT",
3381
+ SESSION_EXPIRED: "SESSION_EXPIRED",
3382
+ SESSION_REVOKED: "SESSION_REVOKED",
3383
+ SESSION_INVALID_REFRESH_TOKEN: "SESSION_INVALID_REFRESH_TOKEN",
3384
+ /** Запрос обновления пришёл без cookie `refresh_token` — продлевать нечего. */
3385
+ REFRESH_TOKEN_MISSING: "REFRESH_TOKEN_MISSING",
3386
+ /** Cookie `refresh_token` есть, но сессии за ней уже нет: отозвана или истекла. */
3387
+ SESSION_NOT_FOUND: "SESSION_NOT_FOUND",
3388
+ MISSING_FLOW_TOKEN: "MISSING_FLOW_TOKEN",
3389
+ PROFILE_USERNAME_TAKEN: "PROFILE_USERNAME_TAKEN",
3390
+ PROFILE_RESTRICTION_ACTIVE: "PROFILE_RESTRICTION_ACTIVE",
3391
+ PROFILE_MODIFICATION_RESTRICTED: "PROFILE_MODIFICATION_RESTRICTED",
3392
+ CONTENT_MODERATION_FAILED: "CONTENT_MODERATION_FAILED",
3393
+ FILE_TOO_LARGE: "FILE_TOO_LARGE",
3394
+ UNSUPPORTED_FILE_TYPE: "UNSUPPORTED_FILE_TYPE",
3395
+ UPLOAD_FAILED: "UPLOAD_FAILED",
3396
+ VIDEO_REQUIRES_VERIFICATION: "VIDEO_REQUIRES_VERIFICATION",
3397
+ PHONE_VERIFICATION_REQUIRED: "PHONE_VERIFICATION_REQUIRED",
3398
+ WRITE_ACCESS_RESTRICTED: "WRITE_ACCESS_RESTRICTED"
3399
+ });
3400
+ //#endregion
3401
+ //#region src/notifications/type-map.ts
3402
+ /**
3403
+ * Соответствие коротких имён типов уведомлений развёрнутым.
3404
+ *
3405
+ * Сервер — и в списке, и в потоке событий — присылает короткие имена: `like`, `comment`,
3406
+ * `reply`, `repost`, `comment_like`. Развёрнутые (`post_reaction`, `post_comment`)
3407
+ * встречаются в оформлении интерфейса, поэтому библиотека приводит типы к ним:
3408
+ * они однозначно называют и объект, и действие.
3409
+ *
3410
+ * Пришедшее значение всегда остаётся в поле `rawType`.
3411
+ */
3412
+ const NOTIFICATION_TYPE_ALIASES = Object.freeze({
3413
+ like: NotificationType.PostReaction,
3414
+ comment: NotificationType.PostComment,
3415
+ comment_like: NotificationType.CommentReaction,
3416
+ reply: NotificationType.CommentReply,
3417
+ repost: NotificationType.PostRepost,
3418
+ mention: NotificationType.PostMention
3419
+ });
3420
+ const KNOWN_TYPES = new Set(Object.values(NotificationType));
3421
+ /**
3422
+ * Приводит имя типа к каноническому.
3423
+ *
3424
+ * Неизвестное значение возвращается без изменений, чтобы не менять смысл нового типа
3425
+ * уведомления на другой.
3426
+ *
3427
+ * @example
3428
+ * ```ts
3429
+ * canonicalNotificationType('like'); // 'post_reaction'
3430
+ * canonicalNotificationType('post_reaction'); // 'post_reaction'
3431
+ * canonicalNotificationType('новое_событие'); // 'новое_событие'
3432
+ * ```
3433
+ */
3434
+ function canonicalNotificationType(rawType) {
3435
+ return NOTIFICATION_TYPE_ALIASES[rawType] ?? rawType;
3436
+ }
3437
+ /**
3438
+ * Известен ли библиотеке этот тип уведомления.
3439
+ *
3440
+ * Полезно, чтобы решить, показывать ли уведомление, для которого нет своего оформления.
3441
+ */
3442
+ function isKnownNotificationType(type) {
3443
+ return KNOWN_TYPES.has(canonicalNotificationType(type));
3444
+ }
3445
+ //#endregion
3446
+ //#region src/notifications/normalize.ts
3447
+ function asActor(value) {
3448
+ if (!isRecord(value)) return void 0;
3449
+ const id = asString(value.id);
3450
+ if (!id) return void 0;
3451
+ return {
3452
+ id,
3453
+ username: asString(value.username) ?? "",
3454
+ displayName: asString(value.displayName) ?? "",
3455
+ avatar: asString(value.avatar) ?? "",
3456
+ ...typeof value.isFollowing === "boolean" ? { isFollowing: value.isFollowing } : {},
3457
+ ...typeof value.isFollowedBy === "boolean" ? { isFollowedBy: value.isFollowedBy } : {}
3458
+ };
3459
+ }
3460
+ /** Собирает участников: сервер присылает либо одного `actor`, либо массив `actors`. */
3461
+ function readActors(source) {
3462
+ if (Array.isArray(source.actors)) return source.actors.map(asActor).filter((actor) => actor !== void 0);
3463
+ const single = asActor(source.actor);
3464
+ return single ? [single] : [];
3465
+ }
3466
+ /**
3467
+ * Приводит уведомление к единой форме.
3468
+ *
3469
+ * Нужна потому, что REST-список и поток событий описывают одно и то же событие по-разному:
3470
+ * различаются имена типов (`like` против `post_reaction`), имена полей
3471
+ * (`targetId`/`entityId`, `read`/`isRead`, `preview`/`entityPreview`) и число участников
3472
+ * (`actor` против массива `actors`). После приведения объекты из обоих источников
3473
+ * можно складывать в один список.
3474
+ *
3475
+ * Исходные данные не теряются: имя типа с сервера остаётся в `rawType`,
3476
+ * весь объект целиком — в `raw`.
3477
+ *
3478
+ * @param input уведомление из REST-ответа либо полезная нагрузка события потока
3479
+ *
3480
+ * @example
3481
+ * ```ts
3482
+ * const fromRest = normalizeNotification(restItem);
3483
+ * const fromStream = normalizeNotification(event.payload);
3484
+ * // одинаковая форма — можно объединять
3485
+ * ```
3486
+ */
3487
+ function normalizeNotification(input) {
3488
+ const source = isRecord(input) ? input : {};
3489
+ const payload = isRecord(source.payload) ? source.payload : source;
3490
+ const rawType = asString(payload.type) ?? asString(source.type) ?? "";
3491
+ const createdAt = asString(payload.createdAt) ?? asString(source.createdAt) ?? "";
3492
+ const readAt = asString(payload.readAt) ?? asString(source.readAt);
3493
+ const isRead = typeof payload.isRead === "boolean" ? payload.isRead : typeof payload.read === "boolean" ? payload.read : Boolean(readAt);
3494
+ const subjectId = asString(payload.subjectId);
3495
+ const targetId = asString(payload.targetId);
3496
+ const subjectIsComment = payload.subjectType === "comment";
3497
+ const clickUrl = asString(payload.clickUrl);
3498
+ return {
3499
+ id: asString(payload.id) ?? asString(source.id) ?? "",
3500
+ type: canonicalNotificationType(rawType),
3501
+ rawType,
3502
+ entityId: asString(payload.entityId) ?? (subjectIsComment ? subjectId ?? targetId : targetId) ?? null,
3503
+ parentEntityId: asString(payload.parentEntityId) ?? (subjectIsComment ? targetId ?? null : null),
3504
+ isRead,
3505
+ actors: readActors(payload),
3506
+ count: typeof payload.count === "number" && payload.count > 0 ? payload.count : 1,
3507
+ preview: asString(payload.entityPreview) ?? asString(payload.preview) ?? null,
3508
+ ...clickUrl ? { clickUrl } : {},
3509
+ createdAt,
3510
+ updatedAt: asString(payload.updatedAt) ?? readAt ?? createdAt,
3511
+ raw: input
3512
+ };
3513
+ }
3514
+ /**
3515
+ * Разбирает событие `notification` из потока.
3516
+ *
3517
+ * Кроме самого уведомления событие несёт служебные поля уровня конверта: актуальный
3518
+ * счётчик непрочитанных и признак звука.
3519
+ */
3520
+ function readNotificationEvent(data) {
3521
+ const source = isRecord(data) ? data : {};
3522
+ return {
3523
+ notification: normalizeNotification(data),
3524
+ unreadCount: typeof source.unreadCount === "number" ? source.unreadCount : void 0,
3525
+ sound: source.sound === true
3526
+ };
3527
+ }
3528
+ /**
3529
+ * Разбирает событие `unread_count` из потока.
3530
+ *
3531
+ * Возвращает `undefined`, если сервер прислал событие без вложенного `payload`.
3532
+ */
3533
+ function readUnreadCountEvent(data) {
3534
+ if (!isRecord(data)) return void 0;
3535
+ const payload = isRecord(data.payload) ? data.payload : void 0;
3536
+ if (!payload) return void 0;
3537
+ return typeof payload.count === "number" ? payload.count : void 0;
3538
+ }
3539
+ //#endregion
3540
+ //#region src/notifications/text.ts
3541
+ /** Имя, которое подставляется, если участник неизвестен. */
3542
+ const UNKNOWN_ACTOR = "Пользователь";
3543
+ /** Текст, который подставляется для неизвестного типа уведомления. */
3544
+ const FALLBACK_TEXT = "Новое уведомление";
3545
+ /**
3546
+ * Шаблоны текстов уведомлений.
3547
+ *
3548
+ * Для каждого типа задано две формы: для одного участника и для схлопнутого уведомления,
3549
+ * где действие совершили несколько человек.
3550
+ */
3551
+ const TEMPLATES = Object.freeze({
3552
+ [NotificationType.Follow]: {
3553
+ one: (name) => `${name} подписался(-ась) на вас`,
3554
+ many: (name, others) => `${name} и ещё ${others} подписались на вас`
3555
+ },
3556
+ [NotificationType.FollowRequest]: {
3557
+ one: (name) => `${name} хочет подписаться на вас`,
3558
+ many: (name, others) => `${name} и ещё ${others} хотят подписаться на вас`
3559
+ },
3560
+ [NotificationType.FollowAccepted]: {
3561
+ one: (name) => `${name} принял(а) вашу заявку`,
3562
+ many: (name, others) => `${name} и ещё ${others} приняли вашу заявку`
3563
+ },
3564
+ [NotificationType.PostReaction]: {
3565
+ one: (name) => `${name} оценил(а) ваш пост`,
3566
+ many: (name, others) => `${name} и ещё ${others} оценили ваш пост`
3567
+ },
3568
+ [NotificationType.PostComment]: {
3569
+ one: (name) => `${name} прокомментировал(а) ваш пост`,
3570
+ many: (name, others) => `${name} и ещё ${others} прокомментировали ваш пост`
3571
+ },
3572
+ [NotificationType.PostRepost]: {
3573
+ one: (name) => `${name} сделал(а) репост`,
3574
+ many: (name, others) => `${name} и ещё ${others} сделали репост`
3575
+ },
3576
+ [NotificationType.CommentReaction]: {
3577
+ one: (name) => `${name} оценил(а) ваш комментарий`,
3578
+ many: (name, others) => `${name} и ещё ${others} оценили ваш комментарий`
3579
+ },
3580
+ [NotificationType.CommentReply]: {
3581
+ one: (name) => `${name} ответил(а) на ваш комментарий`,
3582
+ many: (name, others) => `${name} и ещё ${others} ответили на ваш комментарий`
3583
+ },
3584
+ [NotificationType.PostMention]: {
3585
+ one: (name) => `${name} упомянул(а) вас в посте`,
3586
+ many: (name, others) => `${name} и ещё ${others} упомянули вас в посте`
3587
+ },
3588
+ [NotificationType.CommentMention]: {
3589
+ one: (name) => `${name} упомянул(а) вас в комментарии`,
3590
+ many: (name, others) => `${name} и ещё ${others} упомянули вас в комментарии`
3591
+ },
3592
+ [NotificationType.WallPost]: {
3593
+ one: (name) => `${name} написал(а) на вашей стене`,
3594
+ many: (name, others) => `${name} и ещё ${others} написали на вашей стене`
3595
+ },
3596
+ [NotificationType.VerificationApproved]: {
3597
+ one: () => "Ваша заявка на верификацию одобрена",
3598
+ many: () => "Ваша заявка на верификацию одобрена"
3599
+ },
3600
+ [NotificationType.VerificationRejected]: {
3601
+ one: () => "Ваша заявка на верификацию отклонена",
3602
+ many: () => "Ваша заявка на верификацию отклонена"
3603
+ }
3604
+ });
3605
+ /**
3606
+ * Собирает текст уведомления на русском.
3607
+ *
3608
+ * Повторяет формулировки сайта итд.com. Для неизвестного типа возвращает
3609
+ * «Новое уведомление» — библиотека не выдумывает текст, которого нет.
3610
+ *
3611
+ * @example
3612
+ * ```ts
3613
+ * formatNotificationText(notification);
3614
+ * // 'Аня и ещё 2 оценили ваш пост'
3615
+ * ```
3616
+ */
3617
+ function formatNotificationText(notification) {
3618
+ const template = TEMPLATES[notification.type];
3619
+ if (!template) return FALLBACK_TEXT;
3620
+ const first = notification.actors[0];
3621
+ const name = first?.displayName || first?.username || UNKNOWN_ACTOR;
3622
+ const others = notification.count - 1;
3623
+ return others > 0 ? template.many(name, others) : template.one(name);
3624
+ }
3625
+ //#endregion
3626
+ //#region src/notifications/url.ts
3627
+ /** Типы, ведущие на пост. */
3628
+ const POST_TYPES = /* @__PURE__ */ new Set([
3629
+ NotificationType.PostReaction,
3630
+ NotificationType.PostRepost,
3631
+ NotificationType.PostMention,
3632
+ NotificationType.WallPost
3633
+ ]);
3634
+ /** Типы, ведущие на комментарий внутри поста. */
3635
+ const COMMENT_TYPES = /* @__PURE__ */ new Set([
3636
+ NotificationType.PostComment,
3637
+ NotificationType.CommentReaction,
3638
+ NotificationType.CommentReply,
3639
+ NotificationType.CommentMention
3640
+ ]);
3641
+ /** Типы, ведущие на профиль. */
3642
+ const FOLLOW_TYPES = /* @__PURE__ */ new Set([
3643
+ NotificationType.Follow,
3644
+ NotificationType.FollowRequest,
3645
+ NotificationType.FollowAccepted
3646
+ ]);
3647
+ /**
3648
+ * Вычисляет адрес, на который ведёт уведомление.
3649
+ *
3650
+ * Возвращает путь внутри сайта — без домена, чтобы его можно было передать роутеру
3651
+ * приложения. Поле `clickUrl` от сервера используется только как запасной вариант:
3652
+ * вычисленный путь точнее, поскольку учитывает родительский пост у комментариев.
3653
+ *
3654
+ * @example
3655
+ * ```ts
3656
+ * const url = resolveNotificationUrl(notification);
3657
+ * // '/@nowkie/post/9f1c…?comment=2b7e…'
3658
+ * ```
3659
+ */
3660
+ function resolveNotificationUrl(notification) {
3661
+ const { type, entityId, parentEntityId, clickUrl } = notification;
3662
+ const username = notification.actors[0]?.username;
3663
+ if (username && entityId) {
3664
+ if (POST_TYPES.has(type)) return `/@${username}/post/${entityId}`;
3665
+ if (COMMENT_TYPES.has(type)) return parentEntityId ? `/@${username}/post/${parentEntityId}?comment=${entityId}` : `/@${username}/post/${entityId}`;
3666
+ }
3667
+ if (username && FOLLOW_TYPES.has(type)) return `/@${username}`;
3668
+ return clickUrl || "/notifications";
3669
+ }
3670
+ //#endregion
3671
+ export { RuntimeMode as $, operationRetrySafety as A, maskSecret as B, ViewSource as C, isBuiltInOperationId as D, OPERATIONS as E, pickArray as F, orderPluginDefinitions as G, RetrySafety as H, pickBoolean as I, resolveRateLimit as J, DEFAULT_BASE_URL as K, pickNumber as L, DEFAULT_RATE_LIMIT_BUCKET as M, createClientRuntime as N, operationBucket as O, isRecord as P, RateLimitPacing as Q, pickObject as R, ViewReason as S, ITD_CATALOG as T, RequestQueuePool as U, redactUrl as V, assertPluginRemovable as W, isRecord$1 as X, LIBRARY_VERSION as Y, requireOptionalBoolean as Z, RealtimeStatus as _, readUnreadCountEvent as a, systemClock as at, ServiceState as b, AccessType as c, FeedTab as d, createDeviceId as et, IncidentKind as f, NotificationType as g, LikesVisibility as h, readNotificationEvent as i, createDeadline as it, BUCKET_LIMITS as j, operationMethod as k, AttachmentType as l, ItdErrorCode as m, formatNotificationText as n, isFile as nt, canonicalNotificationType as o, installAsyncDisposeFallback as ot, InteractionType as p, STATUS_SERVICE as q, normalizeNotification as r, supportsStreamingBody as rt, isKnownNotificationType as s, resolveNotificationUrl as t, isBlob as tt, CommentSort as u, ReportReason as v, WallAccess as w, SpanType as x, ReportTargetType as y, pickString as z };
3672
+
3673
+ //# sourceMappingURL=url-CYXgxqGx.js.map