itd-api 0.7.0 → 0.7.2

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