itd-api 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +14 -14
  2. package/dist/events/index.cjs +53 -0
  3. package/dist/events/index.d.cts +3 -0
  4. package/dist/events/index.d.ts +3 -0
  5. package/dist/events/index.js +5 -0
  6. package/dist/index.cjs +269 -175
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +77 -56
  9. package/dist/index.d.ts +77 -56
  10. package/dist/index.js +251 -160
  11. package/dist/index.js.map +1 -1
  12. package/dist/node/index.cjs +4 -4
  13. package/dist/node/index.d.cts +1 -1
  14. package/dist/node/index.d.ts +1 -1
  15. package/dist/node/index.js +4 -4
  16. package/dist/rest/index.cjs +72 -14
  17. package/dist/rest/index.cjs.map +1 -1
  18. package/dist/rest/index.d.cts +11 -3
  19. package/dist/rest/index.d.ts +11 -3
  20. package/dist/rest/index.js +66 -8
  21. package/dist/rest/index.js.map +1 -1
  22. package/dist/shared/{errors-DfU8M5eS.cjs → errors-BmP3TKoW.cjs} +3 -3
  23. package/dist/shared/{errors-DfU8M5eS.cjs.map → errors-BmP3TKoW.cjs.map} +1 -1
  24. package/dist/shared/{errors-Bhrd2fJd.js → errors-GI10kZxk.js} +3 -3
  25. package/dist/shared/{errors-Bhrd2fJd.js.map → errors-GI10kZxk.js.map} +1 -1
  26. package/dist/shared/events-Br2r7coi.d.cts +698 -0
  27. package/dist/shared/events-QyhQZrnC.d.ts +698 -0
  28. package/dist/shared/{websocket-CbzB1Leq.js → events-TfhKh26b.js} +770 -490
  29. package/dist/shared/events-TfhKh26b.js.map +1 -0
  30. package/dist/shared/{websocket-sYlynrr0.cjs → events-iTteVsd_.cjs} +834 -518
  31. package/dist/shared/events-iTteVsd_.cjs.map +1 -0
  32. package/dist/shared/{multi-storage-CkvTUC5m.js → multi-storage-1AEZTczk.js} +4 -4
  33. package/dist/shared/{multi-storage-CkvTUC5m.js.map → multi-storage-1AEZTczk.js.map} +1 -1
  34. package/dist/shared/{multi-storage--yTEqiod.cjs → multi-storage-CCUgkqvI.cjs} +6 -6
  35. package/dist/shared/{multi-storage--yTEqiod.cjs.map → multi-storage-CCUgkqvI.cjs.map} +1 -1
  36. package/dist/shared/{options-DtATYdLr.js → options-BNP633A5.js} +8 -6
  37. package/dist/shared/options-BNP633A5.js.map +1 -0
  38. package/dist/shared/{options-Dg5N3r1V.cjs → options-BUfoHE9G.cjs} +8 -6
  39. package/dist/shared/options-BUfoHE9G.cjs.map +1 -0
  40. package/dist/shared/{cookies-DZwFq6kr.cjs → redact-DB9EMGJx.cjs} +301 -2
  41. package/dist/shared/redact-DB9EMGJx.cjs.map +1 -0
  42. package/dist/shared/{cookies-tX2sNwxb.js → redact-FNK29Fv-.js} +224 -3
  43. package/dist/shared/redact-FNK29Fv-.js.map +1 -0
  44. package/dist/shared/{render-DMp_3Nzk.d.cts → render-BszcKF7S.d.cts} +432 -254
  45. package/dist/shared/{render-vtLixIiU.js → render-CiL1VS0b.js} +1261 -621
  46. package/dist/shared/render-CiL1VS0b.js.map +1 -0
  47. package/dist/shared/{render-DyeHJNBw.cjs → render-DNTESBSl.cjs} +1335 -671
  48. package/dist/shared/render-DNTESBSl.cjs.map +1 -0
  49. package/dist/shared/{render-DZrxhC5_.d.ts → render-DVB7T8pm.d.ts} +432 -254
  50. package/dist/shared/{storage-BPJR_k4-.cjs → storage-BLmXmzKF.cjs} +2 -2
  51. package/dist/shared/{storage-BPJR_k4-.cjs.map → storage-BLmXmzKF.cjs.map} +1 -1
  52. package/dist/shared/{storage-D86edNCB.js → storage-CCxn0Kkv.js} +2 -2
  53. package/dist/shared/{storage-D86edNCB.js.map → storage-CCxn0Kkv.js.map} +1 -1
  54. package/dist/shared/{url-CYXgxqGx.js → url-B3ocFlHj.js} +491 -425
  55. package/dist/shared/url-B3ocFlHj.js.map +1 -0
  56. package/dist/shared/{url-B6-bXHKt.d.ts → url-Be9jlWRv.d.cts} +1233 -1195
  57. package/dist/shared/{url-B6-bXHKt.d.cts → url-Be9jlWRv.d.ts} +1233 -1195
  58. package/dist/shared/{url-yjl2c8Ie.cjs → url-CFN1sT9S.cjs} +562 -478
  59. package/dist/shared/url-CFN1sT9S.cjs.map +1 -0
  60. package/dist/web/index.cjs +2 -2
  61. package/dist/web/index.js +2 -2
  62. package/package.json +16 -15
  63. package/dist/realtime/index.cjs +0 -165
  64. package/dist/realtime/index.cjs.map +0 -1
  65. package/dist/realtime/index.d.cts +0 -51
  66. package/dist/realtime/index.d.ts +0 -51
  67. package/dist/realtime/index.js +0 -120
  68. package/dist/realtime/index.js.map +0 -1
  69. package/dist/shared/auth-provider-BfogACAb.js +0 -91
  70. package/dist/shared/auth-provider-BfogACAb.js.map +0 -1
  71. package/dist/shared/auth-provider-CTJkKfgy.cjs +0 -108
  72. package/dist/shared/auth-provider-CTJkKfgy.cjs.map +0 -1
  73. package/dist/shared/cookies-DZwFq6kr.cjs.map +0 -1
  74. package/dist/shared/cookies-tX2sNwxb.js.map +0 -1
  75. package/dist/shared/options-Dg5N3r1V.cjs.map +0 -1
  76. package/dist/shared/options-DtATYdLr.js.map +0 -1
  77. package/dist/shared/render-DyeHJNBw.cjs.map +0 -1
  78. package/dist/shared/render-vtLixIiU.js.map +0 -1
  79. package/dist/shared/url-CYXgxqGx.js.map +0 -1
  80. package/dist/shared/url-yjl2c8Ie.cjs.map +0 -1
  81. package/dist/shared/websocket-BMtihD56.d.ts +0 -562
  82. package/dist/shared/websocket-CbzB1Leq.js.map +0 -1
  83. package/dist/shared/websocket-D1p32SB2.d.cts +0 -562
  84. package/dist/shared/websocket-sYlynrr0.cjs.map +0 -1
@@ -1,7 +1,752 @@
1
- import { F as pickArray, I as pickBoolean, L as pickNumber, P as isRecord, R as pickObject, et as createDeviceId, nt as isFile, p as InteractionType, q as STATUS_SERVICE, r as normalizeNotification, tt as isBlob, v as ReportReason, x as SpanType, y as ReportTargetType, z as pickString } from "./url-CYXgxqGx.js";
2
- import { a as ItdConfigError, l as ItdFileError, s as ItdError, u as ItdFileErrorReason } from "./errors-Bhrd2fJd.js";
3
- import { o as encodePathSegment } from "./cookies-tX2sNwxb.js";
4
- import { a as fileTooLarge, c as FileTransferMode, i as isReadableByteStream, n as boundedFileStream, o as optionalBytes, r as isBoundedFileStream, s as DEFAULT_URL_FILE_MAX_BYTES, t as resolveFileStreamOptions } from "./options-DtATYdLr.js";
1
+ import { B as isRecord, C as ReportReason, E as SpanType, G as pickString, H as pickBoolean, J as identityResult, K as RetrySafety, M as defineBuiltInOperation, U as pickNumber, V as pickArray, W as pickObject, Y as mergeService, c as normalizeNotification, et as DEFAULT_STATUS_BASE_URL, o as passthroughOperation, q as defineOperation, s as voidOperation, st as createDeadline, tt as STATUS_SERVICE, w as ReportTargetType, y as InteractionType } from "./url-B3ocFlHj.js";
2
+ import { _ as ItdStateError, a as ItdConfigError, l as ItdFileError, s as ItdError, t as ItdAbortError, u as ItdFileErrorReason } from "./errors-GI10kZxk.js";
3
+ import { a as redactUrl, b as isFile, d as encodePathSegment, r as redactErrorCause, v as createDeviceId, y as isBlob } from "./redact-FNK29Fv-.js";
4
+ import { a as fileTooLarge, c as FileTransferMode, i as isReadableByteStream, n as boundedFileStream, o as optionalBytes, r as isBoundedFileStream, s as DEFAULT_URL_FILE_MAX_BYTES, t as resolveFileStreamOptions } from "./options-BNP633A5.js";
5
+ //#region src/core/feature-catalog.ts
6
+ /**
7
+ * Изменяемый слой над каталогом конкретного API.
8
+ *
9
+ * Базовый каталог остаётся замороженным и общим для всех клиентов, а регистрации feature
10
+ * принадлежат одному facade. Поэтому установка модуля в одном клиенте не меняет остальные.
11
+ *
12
+ * @internal
13
+ */
14
+ var ExtensibleOperationCatalog = class {
15
+ #base;
16
+ #operations = /* @__PURE__ */ new Map();
17
+ #operationOwners = /* @__PURE__ */ new Map();
18
+ #bucketOwners = /* @__PURE__ */ new Map();
19
+ #bucketLimits;
20
+ #bucketOverrides;
21
+ constructor(base) {
22
+ this.#base = base;
23
+ this.#bucketLimits = { ...base.bucketLimits };
24
+ this.#bucketOverrides = { ...base.bucketOverrides };
25
+ }
26
+ get bucketLimits() {
27
+ return this.#bucketLimits;
28
+ }
29
+ get bucketOverrides() {
30
+ return this.#bucketOverrides;
31
+ }
32
+ get defaultBucket() {
33
+ return this.#base.defaultBucket;
34
+ }
35
+ definitionOf(id) {
36
+ return this.#operations.get(id) ?? this.#base.definitionOf(id);
37
+ }
38
+ retrySafetyOf(id) {
39
+ return this.#operations.get(id)?.retrySafety ?? this.#base.retrySafetyOf(id);
40
+ }
41
+ methodOf(id) {
42
+ return this.#operations.get(id)?.method ?? this.#base.methodOf(id);
43
+ }
44
+ bucketOf(id) {
45
+ return this.#operations.get(id)?.bucket ?? this.#base.bucketOf(id);
46
+ }
47
+ isKnownBucket(name) {
48
+ return this.#bucketOwners.has(name) || this.#base.isKnownBucket(name);
49
+ }
50
+ /** Регистрирует принадлежащий feature бакет и возвращает откат регистрации. */
51
+ registerBucket(owner, name, definition) {
52
+ if (this.#base.isKnownBucket(name) || this.#bucketOwners.has(name)) throw new ItdConfigError(`feature «${owner}»: бакет «${name}» уже зарегистрирован`);
53
+ this.#bucketOwners.set(name, owner);
54
+ if (definition.limit !== void 0) this.#bucketLimits[name] = definition.limit;
55
+ if (definition.concurrency !== void 0 || definition.rps !== void 0) this.#bucketOverrides[name] = Object.freeze({
56
+ ...definition.concurrency === void 0 ? {} : { concurrency: definition.concurrency },
57
+ ...definition.rps === void 0 ? {} : { rps: definition.rps }
58
+ });
59
+ return () => {
60
+ if (this.#bucketOwners.get(name) !== owner) return;
61
+ this.#bucketOwners.delete(name);
62
+ delete this.#bucketLimits[name];
63
+ delete this.#bucketOverrides[name];
64
+ };
65
+ }
66
+ /** Регистрирует операцию и возвращает откат регистрации. */
67
+ registerOperation(owner, id, definition) {
68
+ const ownerPrefix = `${owner}.`;
69
+ if (!id.startsWith(ownerPrefix) || id.length === ownerPrefix.length) throw new ItdConfigError(`feature «${owner}»: операция «${id}» должна находиться в пространстве имён «${owner}»`);
70
+ const occupiedBy = this.#operationOwners.get(id);
71
+ if (occupiedBy !== void 0) throw new ItdConfigError(`feature «${owner}»: операция «${id}» уже зарегистрирована feature «${occupiedBy}»`);
72
+ if (this.#base.methodOf(id) !== void 0) throw new ItdConfigError(`feature «${owner}»: операция «${id}» уже существует в базовом каталоге`);
73
+ this.#operationOwners.set(id, owner);
74
+ this.#operations.set(id, Object.freeze({ ...definition }));
75
+ return () => {
76
+ if (this.#operationOwners.get(id) !== owner) return;
77
+ this.#operationOwners.delete(id);
78
+ this.#operations.delete(id);
79
+ };
80
+ }
81
+ };
82
+ //#endregion
83
+ //#region src/core/attachments/url-source.ts
84
+ /** Убирает параметры MIME и приводит его к форме для сравнения. */
85
+ function normalizeMimeType(contentType) {
86
+ return contentType?.split(";", 1)[0]?.trim().toLowerCase() || void 0;
87
+ }
88
+ /** Достаёт имя файла из пути URL. */
89
+ function filenameFromUrl(url) {
90
+ const last = url.pathname.split("/").pop();
91
+ if (!last) return void 0;
92
+ try {
93
+ return decodeURIComponent(last) || void 0;
94
+ } catch {
95
+ return last;
96
+ }
97
+ }
98
+ /** Проверяет объявленный размер и возвращает его, если заголовок корректен. */
99
+ function declaredSize(response, maxBytes, url) {
100
+ const header = response.headers.get("content-length");
101
+ if (header === null) return void 0;
102
+ const size = Number(header);
103
+ if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) return void 0;
104
+ if (maxBytes !== void 0 && size > maxBytes) throw fileTooLarge(url, maxBytes, size);
105
+ return size;
106
+ }
107
+ /** Получает HTTP-ответ источника и проверяет его статус. */
108
+ async function fetchFile(target, options, context) {
109
+ let requested;
110
+ try {
111
+ requested = new URL(target);
112
+ } catch {
113
+ throw new ItdConfigError(`«${redactUrl(target)}» не разбирается как адрес`);
114
+ }
115
+ if (requested.protocol !== "http:" && requested.protocol !== "https:") throw new ItdConfigError(`вложение по адресу поддерживает только http и https, получено: ${requested.protocol}`);
116
+ let response;
117
+ try {
118
+ response = await context.fetch(requested, { ...context.signal ? { signal: context.signal } : {} });
119
+ } catch (error) {
120
+ if (context.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
121
+ const safeUrl = redactUrl(requested.href);
122
+ throw new ItdFileError(`не удалось получить файл по адресу ${safeUrl}`, {
123
+ reason: ItdFileErrorReason.Network,
124
+ url: safeUrl,
125
+ retryable: true,
126
+ cause: redactErrorCause(error)
127
+ });
128
+ }
129
+ const finalUrl = response.url ? new URL(response.url) : requested;
130
+ if (!response.ok) {
131
+ await response.body?.cancel().catch(() => {});
132
+ const safeUrl = redactUrl(finalUrl.href);
133
+ throw new ItdFileError(`источник ${safeUrl} ответил статусом ${response.status}`, {
134
+ reason: ItdFileErrorReason.Http,
135
+ url: safeUrl,
136
+ status: response.status,
137
+ retryable: response.status === 408 || response.status === 429 || response.status >= 500
138
+ });
139
+ }
140
+ const { maxBytes } = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
141
+ let size;
142
+ try {
143
+ size = declaredSize(response, maxBytes, finalUrl.href);
144
+ } catch (error) {
145
+ await response.body?.cancel().catch(() => {});
146
+ throw error;
147
+ }
148
+ return {
149
+ response,
150
+ url: finalUrl,
151
+ size
152
+ };
153
+ }
154
+ /** Читает ответ с контролем размера до создания итогового `Blob`. */
155
+ async function responseBlob(response, url, maxBytes, streamBufferBytes, signal) {
156
+ if (!response.body) {
157
+ const blob = await response.blob();
158
+ if (maxBytes !== void 0 && blob.size > maxBytes) throw fileTooLarge(url, maxBytes, blob.size);
159
+ return blob;
160
+ }
161
+ const chunks = [];
162
+ const reader = boundedFileStream(response.body, {
163
+ ...maxBytes !== void 0 ? { maxBytes } : {},
164
+ streamBufferBytes,
165
+ ...signal ? { signal } : {},
166
+ url,
167
+ retryableRead: true
168
+ }).getReader();
169
+ try {
170
+ for (;;) {
171
+ const next = await reader.read();
172
+ if (next.done) break;
173
+ chunks.push(next.value);
174
+ }
175
+ } finally {
176
+ reader.releaseLock();
177
+ }
178
+ return new Blob(chunks.map((chunk) => Uint8Array.from(chunk).buffer));
179
+ }
180
+ /** Скачивает файл целиком с ограничением размера. @internal */
181
+ async function downloadFile(target, options, context) {
182
+ const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
183
+ const { response, url } = await fetchFile(target, options, context);
184
+ const blob = await responseBlob(response, url.href, resolved.maxBytes, resolved.streamBufferBytes, context.signal);
185
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
186
+ const filename = options.filename ?? filenameFromUrl(url);
187
+ return {
188
+ file: new Blob([blob], { type: contentType ?? "" }),
189
+ ...filename ? { filename } : {},
190
+ ...contentType ? { contentType } : {}
191
+ };
192
+ }
193
+ /** Открывает HTTP-ответ как ограниченный поток. @internal */
194
+ async function openUrlFile(target, options, context) {
195
+ const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
196
+ const { response, url, size } = await fetchFile(target, options, context);
197
+ if (!response.body) {
198
+ const safeUrl = redactUrl(url.href);
199
+ throw new ItdFileError(`источник ${safeUrl} не предоставил потоковое тело`, {
200
+ reason: ItdFileErrorReason.StreamUnavailable,
201
+ url: safeUrl
202
+ });
203
+ }
204
+ const stream = boundedFileStream(response.body, {
205
+ ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
206
+ streamBufferBytes: resolved.streamBufferBytes,
207
+ ...context.signal ? { signal: context.signal } : {},
208
+ url: url.href,
209
+ retryableRead: true
210
+ });
211
+ const filename = options.filename ?? filenameFromUrl(url);
212
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
213
+ return {
214
+ stream,
215
+ ...filename ? { filename } : {},
216
+ ...contentType ? { contentType } : {},
217
+ ...size !== void 0 ? { size } : {},
218
+ close: () => stream.cancel().catch(() => {})
219
+ };
220
+ }
221
+ //#endregion
222
+ //#region src/core/attachments/resolver.ts
223
+ /** Возвращает функцию освобождения, которая исполняет действие не более одного раза. */
224
+ function closeOnce(action) {
225
+ let closing;
226
+ return () => closing ??= Promise.resolve().then(action);
227
+ }
228
+ /** Добавляет поле только для непустого значения, не выдумывая имя или MIME. */
229
+ function optionalText(name, value) {
230
+ const normalized = value?.trim();
231
+ return normalized ? { [name]: normalized } : {};
232
+ }
233
+ /** Единая реализация распознавания и открытия `FileInput`. @internal */
234
+ var DefaultFileResolver = class {
235
+ #fetch;
236
+ constructor(fetchImplementation) {
237
+ this.#fetch = fetchImplementation;
238
+ }
239
+ mode(input) {
240
+ if (typeof input !== "object" || input === null) return FileTransferMode.Buffer;
241
+ if ("open" in input) return FileTransferMode.Stream;
242
+ if ("url" in input) return resolveFileStreamOptions(input).mode;
243
+ return FileTransferMode.Buffer;
244
+ }
245
+ async resolve(input, options = {}, context = {}) {
246
+ const attempt = context.attempt ?? 1;
247
+ if (!Number.isInteger(attempt) || attempt < 1) throw new ItdConfigError(`attempt должен быть целым числом от 1, получено: ${attempt}`);
248
+ const maxBytes = optionalBytes(options.maxBytes, "maxBytes");
249
+ if (context.signal?.aborted) throw context.signal.reason ?? new DOMException("Aborted", "AbortError");
250
+ const fileContext = {
251
+ fetch: this.#fetch,
252
+ attempt,
253
+ ...context.signal ? { signal: context.signal } : {}
254
+ };
255
+ try {
256
+ const source = this.mode(input) === FileTransferMode.Stream ? await this.#openStream(input, options, fileContext, maxBytes) : await this.#loadBuffer(input, options, fileContext, maxBytes);
257
+ if (context.signal?.aborted) {
258
+ if (source.mode === FileTransferMode.Stream) await source.close();
259
+ throw context.signal.reason ?? new DOMException("Aborted", "AbortError");
260
+ }
261
+ return source;
262
+ } catch (error) {
263
+ if (error instanceof ItdError || context.signal?.aborted) throw error;
264
+ throw new ItdFileError("не удалось получить содержимое вложения", {
265
+ reason: ItdFileErrorReason.Read,
266
+ retryable: true,
267
+ cause: redactErrorCause(error)
268
+ });
269
+ }
270
+ }
271
+ async #loadBuffer(input, options, context, maxBytes) {
272
+ const content = await this.#resolveBuffer(input, context);
273
+ if (typeof content !== "object" || content === null || !("file" in content)) throw new ItdConfigError("буферный источник должен вернуть объект с полем file");
274
+ if (!isBlob(content.file) && !(content.file instanceof ArrayBuffer) && !ArrayBuffer.isView(content.file)) throw new ItdConfigError("поле file должно содержать Blob, ArrayBuffer или Uint8Array");
275
+ const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(isBlob(content.file) ? content.file.type : void 0);
276
+ const blob = isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
277
+ if (maxBytes !== void 0 && blob.size > maxBytes) throw fileTooLarge(void 0, maxBytes, blob.size);
278
+ return {
279
+ mode: FileTransferMode.Buffer,
280
+ blob,
281
+ size: blob.size,
282
+ ...optionalText("filename", options.filename ?? content.filename),
283
+ contentType
284
+ };
285
+ }
286
+ async #openStream(input, options, context, maxBytes) {
287
+ const opened = await this.#resolveStream(input, context);
288
+ if (!opened || !isReadableByteStream(opened.stream)) {
289
+ await opened?.close?.();
290
+ throw new ItdConfigError("потоковый источник должен вернуть { stream: ReadableStream }");
291
+ }
292
+ let handedOff = false;
293
+ const closeOpened = closeOnce(async () => {
294
+ await opened.close?.();
295
+ });
296
+ try {
297
+ optionalBytes(opened.size, "size");
298
+ if (maxBytes !== void 0 && opened.size !== void 0 && opened.size > maxBytes) throw fileTooLarge(void 0, maxBytes, opened.size);
299
+ if (options.requireKnownSize === true && opened.size === void 0) throw new ItdConfigError("размер потокового вложения должен быть известен до чтения");
300
+ const limits = resolveFileStreamOptions({
301
+ mode: FileTransferMode.Stream,
302
+ ...maxBytes !== void 0 ? { maxBytes } : {},
303
+ ...options.streamBufferBytes !== void 0 ? { streamBufferBytes: options.streamBufferBytes } : {}
304
+ });
305
+ const stream = isBoundedFileStream(opened.stream) && options.maxBytes === void 0 && options.streamBufferBytes === void 0 ? opened.stream : boundedFileStream(opened.stream, {
306
+ ...limits.maxBytes !== void 0 ? { maxBytes: limits.maxBytes } : {},
307
+ streamBufferBytes: limits.streamBufferBytes,
308
+ ...context.signal ? { signal: context.signal } : {}
309
+ });
310
+ let detachAbort;
311
+ const close = closeOnce(async () => {
312
+ detachAbort?.();
313
+ detachAbort = void 0;
314
+ await stream.cancel().catch(() => {});
315
+ await closeOpened();
316
+ });
317
+ if (context.signal) {
318
+ const onAbort = () => {
319
+ close().catch(() => {});
320
+ };
321
+ if (context.signal.aborted) {
322
+ await close();
323
+ throw context.signal.reason ?? new DOMException("Aborted", "AbortError");
324
+ } else {
325
+ context.signal.addEventListener("abort", onAbort, { once: true });
326
+ detachAbort = () => context.signal?.removeEventListener("abort", onAbort);
327
+ }
328
+ }
329
+ handedOff = true;
330
+ return {
331
+ mode: FileTransferMode.Stream,
332
+ stream,
333
+ size: opened.size,
334
+ ...optionalText("filename", options.filename ?? opened.filename),
335
+ contentType: normalizeMimeType(options.contentType ?? opened.contentType),
336
+ close
337
+ };
338
+ } finally {
339
+ if (!handedOff) await closeOpened();
340
+ }
341
+ }
342
+ #resolveBuffer(input, context) {
343
+ if (input instanceof ArrayBuffer || ArrayBuffer.isView(input) || isBlob(input)) return {
344
+ file: input,
345
+ ...isFile(input) ? { filename: input.name } : {}
346
+ };
347
+ if (typeof input !== "object" || input === null) throw new ItdConfigError(`вложение задано значением типа ${typeof input}; ожидается бинарное значение или объект { file }, { url }, { load }, { open }. Для файла на диске используйте fromPath('./photo.jpg') из itd-api/node`);
348
+ if ("load" in input) return input.load(context);
349
+ if ("url" in input) {
350
+ const { url, ...urlOptions } = input;
351
+ return downloadFile(url, urlOptions, context);
352
+ }
353
+ if ("file" in input) return input;
354
+ if ("open" in input) throw new ItdConfigError("потоковый источник нельзя использовать как буферный");
355
+ throw new ItdConfigError("вложение не распознано: ожидается { file }, { url }, { load } или { open }");
356
+ }
357
+ #resolveStream(input, context) {
358
+ if (typeof input === "object" && input !== null && "open" in input) return input.open(context);
359
+ if (typeof input === "object" && input !== null && "url" in input) {
360
+ const { url, ...urlOptions } = input;
361
+ return openUrlFile(url, urlOptions, context);
362
+ }
363
+ throw new ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
364
+ }
365
+ };
366
+ /** Создаёт механизм подготовки файлов, привязанный к настроенному `fetch` клиента. @internal */
367
+ function createFileResolver(fetchImplementation) {
368
+ return new DefaultFileResolver(fetchImplementation);
369
+ }
370
+ //#endregion
371
+ //#region src/core/features.ts
372
+ function requireName(value, kind) {
373
+ if (typeof value !== "string" || value.trim() === "") throw new ItdConfigError(`${kind} должен иметь непустое имя`);
374
+ return value.trim();
375
+ }
376
+ function requireFeatureName(value) {
377
+ const name = requireName(value, "Feature");
378
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new ItdConfigError(`имя feature «${name}» должно соответствовать [a-z][a-z0-9-]*`);
379
+ return name;
380
+ }
381
+ function validateBucket(owner, localName, bucket) {
382
+ if (typeof bucket !== "object" || bucket === null || Array.isArray(bucket)) throw new ItdConfigError(`feature «${owner}»: бакет «${localName}» должен быть объектом`);
383
+ if (bucket.limit !== void 0 && (!Number.isFinite(bucket.limit) || bucket.limit <= 0)) throw new ItdConfigError(`feature «${owner}»: limit бакета «${localName}» должен быть положительным числом`);
384
+ if (bucket.concurrency !== void 0 && (!Number.isInteger(bucket.concurrency) || bucket.concurrency < 1)) throw new ItdConfigError(`feature «${owner}»: concurrency бакета «${localName}» должен быть целым числом от 1`);
385
+ if (bucket.rps !== void 0 && (!Number.isFinite(bucket.rps) || bucket.rps <= 0)) throw new ItdConfigError(`feature «${owner}»: rps бакета «${localName}» должен быть положительным числом`);
386
+ }
387
+ /** Реестр подключаемых модулей клиента. @internal */
388
+ var FeatureRegistry = class {
389
+ #deps;
390
+ #features = /* @__PURE__ */ new Map();
391
+ #serviceOwners = /* @__PURE__ */ new Map();
392
+ #disposed = false;
393
+ constructor(deps) {
394
+ this.#deps = deps;
395
+ }
396
+ install(feature) {
397
+ this.#deps.assertActive("установить feature");
398
+ if (this.#disposed) throw new ItdStateError("Реестр feature уже освобождён");
399
+ if (typeof feature !== "object" || feature === null) throw new ItdConfigError("feature должен быть объектом");
400
+ const name = requireFeatureName(feature.name);
401
+ if (this.#features.has(name)) throw new ItdConfigError(`feature «${name}» уже установлен`);
402
+ if (typeof feature.setup !== "function") throw new ItdConfigError(`feature «${name}» должен предоставлять setup()`);
403
+ if (typeof feature.operations !== "object" || feature.operations === null || Array.isArray(feature.operations)) throw new ItdConfigError(`feature «${name}» должен объявлять operations`);
404
+ if (feature.services !== void 0 && !Array.isArray(feature.services)) throw new ItdConfigError(`feature «${name}»: services должен быть массивом`);
405
+ if (feature.buckets !== void 0 && (typeof feature.buckets !== "object" || feature.buckets === null || Array.isArray(feature.buckets))) throw new ItdConfigError(`feature «${name}»: buckets должен быть объектом`);
406
+ const rollback = [];
407
+ const services = /* @__PURE__ */ new Set();
408
+ const bucketNames = /* @__PURE__ */ new Map();
409
+ const operations = /* @__PURE__ */ new Map();
410
+ const controller = new AbortController();
411
+ let committed = false;
412
+ const reportRollbackError = (message, error) => {
413
+ try {
414
+ this.#deps.logger?.error(message, error);
415
+ } catch {}
416
+ };
417
+ try {
418
+ for (const service of feature.services ?? []) {
419
+ const serviceName = requireName(service?.name, `Сервис feature «${name}»`);
420
+ const owner = this.#serviceOwners.get(serviceName);
421
+ if (owner !== void 0) throw new ItdConfigError(`feature «${name}»: сервис «${serviceName}» уже принадлежит feature «${owner}»`);
422
+ if (services.has(serviceName)) throw new ItdConfigError(`feature «${name}»: сервис «${serviceName}» объявлен повторно`);
423
+ if (!this.#deps.services.has(serviceName)) {
424
+ this.#deps.services.define(service);
425
+ rollback.push(() => void this.#deps.services.delete(serviceName));
426
+ } else {
427
+ const override = this.#deps.serviceOverrides.find((candidate) => candidate.name.trim() === serviceName);
428
+ if (override) {
429
+ const previous = this.#deps.services.replace(mergeService(service, override));
430
+ rollback.push(() => void this.#deps.services.replace(previous));
431
+ }
432
+ }
433
+ this.#serviceOwners.set(serviceName, name);
434
+ rollback.push(() => {
435
+ if (this.#serviceOwners.get(serviceName) === name) this.#serviceOwners.delete(serviceName);
436
+ });
437
+ services.add(serviceName);
438
+ }
439
+ for (const [rawLocalName, bucket] of Object.entries(feature.buckets ?? {})) {
440
+ const localName = requireName(rawLocalName, `Бакет feature «${name}»`);
441
+ validateBucket(name, localName, bucket);
442
+ const globalName = `feature:${name}/${localName}`;
443
+ const unregisterCatalog = this.#deps.catalog.registerBucket(name, globalName, bucket);
444
+ rollback.push(unregisterCatalog);
445
+ const unregisterQueue = this.#deps.registerBucket(globalName, bucket);
446
+ if (unregisterQueue) rollback.push(unregisterQueue);
447
+ bucketNames.set(localName, globalName);
448
+ }
449
+ for (const [rawLocalName, definition] of Object.entries(feature.operations)) {
450
+ const localName = requireName(rawLocalName, `Операция feature «${name}»`);
451
+ if (typeof definition !== "object" || definition === null) throw new ItdConfigError(`feature «${name}»: операция «${localName}» должна быть объектом`);
452
+ if (![
453
+ "GET",
454
+ "POST",
455
+ "PUT",
456
+ "PATCH",
457
+ "DELETE"
458
+ ].includes(definition.method)) throw new ItdConfigError(`feature «${name}»: операция «${localName}» содержит неизвестный HTTP-метод`);
459
+ if (!Object.values(RetrySafety).includes(definition.retrySafety)) throw new ItdConfigError(`feature «${name}»: операция «${localName}» содержит неизвестную retrySafety`);
460
+ if (definition.service !== void 0 && typeof definition.service !== "string") throw new ItdConfigError(`feature «${name}»: service операции «${localName}» должен быть строкой`);
461
+ if (definition.bucket !== void 0 && typeof definition.bucket !== "string") throw new ItdConfigError(`feature «${name}»: bucket операции «${localName}» должен быть строкой`);
462
+ if (definition.read !== void 0 && typeof definition.read !== "function") throw new ItdConfigError(`feature «${name}»: read операции «${localName}» должна быть функцией`);
463
+ if (definition.annotations !== void 0 && (typeof definition.annotations !== "object" || definition.annotations === null || Array.isArray(definition.annotations))) throw new ItdConfigError(`feature «${name}»: annotations операции «${localName}» должны быть объектом`);
464
+ if (definition.service !== void 0 && !services.has(definition.service)) throw new ItdConfigError(`feature «${name}»: операция «${localName}» ссылается на необъявленный сервис «${definition.service}»`);
465
+ const bucket = definition.bucket === void 0 ? this.#deps.catalog.defaultBucket : bucketNames.get(definition.bucket);
466
+ if (bucket === void 0) throw new ItdConfigError(`feature «${name}»: операция «${localName}» ссылается на необъявленный бакет «${definition.bucket}»`);
467
+ const operationId = `${name}.${localName}`;
468
+ const contract = defineOperation(operationId, {
469
+ method: definition.method,
470
+ retrySafety: definition.retrySafety,
471
+ bucket,
472
+ ...definition.annotations === void 0 ? {} : { annotations: definition.annotations }
473
+ }, definition.read ?? identityResult);
474
+ const unregister = this.#deps.catalog.registerOperation(name, operationId, {
475
+ method: contract.method,
476
+ retrySafety: contract.retrySafety,
477
+ bucket,
478
+ ...contract.annotations === void 0 ? {} : { annotations: contract.annotations }
479
+ });
480
+ rollback.push(unregister);
481
+ operations.set(localName, {
482
+ contract,
483
+ service: definition.service
484
+ });
485
+ }
486
+ const context = Object.freeze({
487
+ featureName: name,
488
+ baseUrl: this.#deps.baseUrl,
489
+ signal: controller.signal,
490
+ clock: this.#deps.clock,
491
+ logger: this.#deps.logger,
492
+ files: Object.freeze({ resolve: (input, options, context) => {
493
+ try {
494
+ this.#deps.assertActive(`открыть файловый источник feature «${name}»`);
495
+ if (!committed || controller.signal.aborted) throw new ItdStateError(`feature «${name}» не активен`);
496
+ return this.#deps.files.resolve(input, options, context);
497
+ } catch (error) {
498
+ return Promise.reject(error);
499
+ }
500
+ } }),
501
+ request: (operation, options) => {
502
+ try {
503
+ this.#deps.assertActive(`выполнить операцию feature «${name}»`);
504
+ if (!committed || controller.signal.aborted) throw new ItdStateError(`feature «${name}» не активен`);
505
+ const resolved = operations.get(operation);
506
+ if (!resolved) throw new ItdConfigError(`feature «${name}» не объявляет операцию «${operation}»`);
507
+ const scoped = { ...options };
508
+ delete scoped.operationId;
509
+ delete scoped.method;
510
+ delete scoped.service;
511
+ delete scoped.baseUrl;
512
+ delete scoped.retrySafety;
513
+ delete scoped.rateLimitBucket;
514
+ return this.#deps.http.execute(resolved.contract, {
515
+ ...scoped,
516
+ ...resolved.service === void 0 ? {} : { service: resolved.service }
517
+ });
518
+ } catch (error) {
519
+ return Promise.reject(error);
520
+ }
521
+ },
522
+ serviceBaseUrl: (serviceName) => {
523
+ if (!services.has(serviceName)) throw new ItdConfigError(`feature «${name}» не объявляет сервис «${serviceName}»`);
524
+ return this.#deps.services.resolveBaseUrl(serviceName);
525
+ },
526
+ connection: (serviceName) => {
527
+ if (!services.has(serviceName)) throw new ItdConfigError(`feature «${name}» не объявляет сервис «${serviceName}»`);
528
+ return this.#deps.connection(serviceName);
529
+ },
530
+ manage: (resource) => {
531
+ this.#deps.assertActive(`зарегистрировать ресурс feature «${name}»`);
532
+ if (controller.signal.aborted) throw new ItdStateError(`feature «${name}» не активен`);
533
+ const unregister = this.#deps.manage(resource);
534
+ let registered = true;
535
+ const release = () => {
536
+ if (!registered) return;
537
+ registered = false;
538
+ unregister();
539
+ };
540
+ if (!committed) rollback.push(() => {
541
+ if (!registered) return;
542
+ if (committed) {
543
+ release();
544
+ return;
545
+ }
546
+ try {
547
+ resource.stop();
548
+ } catch (error) {
549
+ reportRollbackError(`Не удалось остановить ресурс «${resource.kind}» при откате feature «${name}»`, error);
550
+ } finally {
551
+ release();
552
+ }
553
+ Promise.resolve().then(() => resource.drain()).catch((error) => {
554
+ reportRollbackError(`Не удалось освободить ресурс «${resource.kind}» при откате feature «${name}»`, error);
555
+ });
556
+ });
557
+ return release;
558
+ },
559
+ assertActive: (action) => {
560
+ this.#deps.assertActive(action);
561
+ if (!committed || controller.signal.aborted) throw new ItdStateError(`feature «${name}» не активен`);
562
+ }
563
+ });
564
+ const installation = feature.setup(context);
565
+ if (typeof installation !== "object" || installation === null || !("api" in installation)) throw new ItdConfigError(`setup() feature «${name}» должен вернуть объект с полем api`);
566
+ if (installation.close !== void 0 && typeof installation.close !== "function") throw new ItdConfigError(`feature «${name}»: close должен быть функцией`);
567
+ if (installation.dispose !== void 0 && typeof installation.dispose !== "function") throw new ItdConfigError(`feature «${name}»: dispose должен быть функцией`);
568
+ this.#features.set(name, {
569
+ name,
570
+ controller,
571
+ close: installation.close,
572
+ dispose: installation.dispose,
573
+ cleanup: [...rollback]
574
+ });
575
+ committed = true;
576
+ return installation.api;
577
+ } catch (error) {
578
+ controller.abort(new ItdAbortError(`Установка feature «${name}» отменена`));
579
+ for (const undo of rollback.reverse()) undo();
580
+ throw error;
581
+ }
582
+ }
583
+ names() {
584
+ return [...this.#features.keys()];
585
+ }
586
+ has(name) {
587
+ return this.#features.has(name);
588
+ }
589
+ async close() {
590
+ const errors = (await Promise.allSettled([...this.#features.values()].reverse().map(async (feature) => feature.close?.()))).filter((result) => result.status === "rejected").map((result) => result.reason);
591
+ if (errors.length > 0) throw new AggregateError(errors, "Не удалось закрыть feature");
592
+ }
593
+ async dispose() {
594
+ if (this.#disposed) return;
595
+ this.#disposed = true;
596
+ const errors = [];
597
+ const disposals = [];
598
+ const features = [...this.#features.values()].reverse();
599
+ for (const feature of features) {
600
+ feature.controller.abort(new ItdAbortError(`Feature «${feature.name}» освобождён`));
601
+ try {
602
+ disposals.push(Promise.resolve(feature.dispose?.()).then(() => void 0));
603
+ } catch (error) {
604
+ errors.push(error);
605
+ }
606
+ }
607
+ for (const feature of features) for (const cleanup of [...feature.cleanup].reverse()) try {
608
+ cleanup();
609
+ } catch (error) {
610
+ errors.push(error);
611
+ }
612
+ this.#features.clear();
613
+ const results = await Promise.allSettled(disposals);
614
+ for (const result of results) if (result.status === "rejected") errors.push(result.reason);
615
+ if (errors.length > 0) throw new AggregateError(errors, "Не удалось освободить feature");
616
+ }
617
+ };
618
+ //#endregion
619
+ //#region src/core/managed-resources.ts
620
+ /** Реестр активных ресурсов клиента. @internal */
621
+ var ManagedResourceRegistry = class {
622
+ #resources = /* @__PURE__ */ new Set();
623
+ #disposed = false;
624
+ register(resource) {
625
+ if (this.#disposed) throw new ItdStateError("Lifecycle клиента уже освобождён");
626
+ if (typeof resource !== "object" || resource === null || typeof resource.kind !== "string" || resource.kind.trim() === "" || typeof resource.stop !== "function" || typeof resource.drain !== "function") throw new ItdConfigError("Managed resource должен предоставлять kind, stop() и drain()");
627
+ this.#resources.add(resource);
628
+ let registered = true;
629
+ return () => {
630
+ if (!registered) return;
631
+ registered = false;
632
+ this.#resources.delete(resource);
633
+ };
634
+ }
635
+ /** Останавливает snapshot синхронно, чтобы новые auth-данные ещё не были опубликованы. */
636
+ stopAll() {
637
+ const resources = [...this.#resources];
638
+ const errors = [];
639
+ for (const resource of resources) try {
640
+ resource.stop();
641
+ } catch (error) {
642
+ errors.push(error);
643
+ }
644
+ return {
645
+ resources,
646
+ errors
647
+ };
648
+ }
649
+ dispose() {
650
+ this.#disposed = true;
651
+ this.#resources.clear();
652
+ }
653
+ };
654
+ //#endregion
655
+ //#region src/core/feature-host.ts
656
+ /**
657
+ * Управляет подключаемыми модулями и их ресурсами.
658
+ *
659
+ * @internal
660
+ */
661
+ var ClientFeatureHost = class {
662
+ #runtime;
663
+ #features;
664
+ #resources = new ManagedResourceRegistry();
665
+ /** Общий файловый порт ресурсов и подключаемых модулей этого клиента. @internal */
666
+ files;
667
+ constructor(runtime, options) {
668
+ this.#runtime = runtime;
669
+ this.files = createFileResolver(runtime.config.fetch);
670
+ this.#features = new FeatureRegistry({
671
+ http: runtime.http,
672
+ services: runtime.services,
673
+ serviceOverrides: runtime.config.services,
674
+ catalog: options.catalog,
675
+ baseUrl: runtime.config.baseUrl,
676
+ clock: runtime.config.clock,
677
+ logger: runtime.config.logger,
678
+ files: this.files,
679
+ assertActive: options.assertActive,
680
+ connection: (serviceName) => runtime.connection(serviceName),
681
+ manage: (resource) => this.#resources.register(resource),
682
+ registerBucket: (name, definition) => runtime.registerRateLimitBucket(name, definition)
683
+ });
684
+ }
685
+ install(feature) {
686
+ return this.#features.install(feature);
687
+ }
688
+ names() {
689
+ return this.#features.names();
690
+ }
691
+ has(name) {
692
+ return this.#features.has(name);
693
+ }
694
+ /** Регистрирует ресурс самого клиента. @internal */
695
+ manage(resource) {
696
+ return this.#resources.register(resource);
697
+ }
698
+ /** Останавливает ресурсы перед сменой авторизации. */
699
+ stop() {
700
+ const { errors } = this.#resources.stopAll();
701
+ if (errors.length > 0) this.#runtime.config.logger?.error("Не удалось остановить ресурсы клиента", errors);
702
+ }
703
+ /** Останавливает ресурсы и ждёт функции закрытия модулей. */
704
+ async close() {
705
+ const snapshot = this.#resources.stopAll();
706
+ const errors = [...snapshot.errors];
707
+ const deadline = createDeadline(this.#runtime.config.shutdownTimeout, this.#runtime.config.clock);
708
+ const tasks = [...snapshot.resources.map((resource) => ({
709
+ kind: resource.kind,
710
+ promise: Promise.resolve().then(() => resource.drain())
711
+ })), {
712
+ kind: "feature lifecycle",
713
+ promise: Promise.resolve().then(() => this.#features.close())
714
+ }];
715
+ try {
716
+ const stuck = (await Promise.all(tasks.map(async ({ kind, promise }) => {
717
+ const result = promise.catch((error) => {
718
+ errors.push(error);
719
+ });
720
+ return await deadline.wait(result) ? void 0 : kind;
721
+ }))).filter((kind) => kind !== void 0);
722
+ if (stuck.length > 0) errors.push(new ItdStateError(`ресурсы (${stuck.join(", ")}) не завершились за ${this.#runtime.config.shutdownTimeout} мс`));
723
+ } finally {
724
+ deadline.cancel();
725
+ }
726
+ if (errors.length === 1) throw errors[0];
727
+ if (errors.length > 1) throw new AggregateError(errors, "Не удалось закрыть feature-host");
728
+ }
729
+ async dispose() {
730
+ this.#resources.dispose();
731
+ const deadline = createDeadline(this.#runtime.config.shutdownTimeout, this.#runtime.config.clock);
732
+ let failed = false;
733
+ let failure;
734
+ const settled = this.#features.dispose().catch((error) => {
735
+ failed = true;
736
+ failure = error;
737
+ });
738
+ try {
739
+ if (!await deadline.wait(settled)) throw new ItdStateError(`освобождение подключаемых модулей не завершилось за ${this.#runtime.config.shutdownTimeout} мс`);
740
+ } finally {
741
+ deadline.cancel();
742
+ }
743
+ if (failed) throw failure;
744
+ }
745
+ };
746
+ function createFeatureHost(runtime, options) {
747
+ return new ClientFeatureHost(runtime, options);
748
+ }
749
+ //#endregion
5
750
  //#region src/resources/pagination.ts
6
751
  /** Схема пагинации эндпоинта. */
7
752
  const PaginationMode = Object.freeze({
@@ -119,6 +864,10 @@ function readOffsetPage(body, field, offset) {
119
864
  raw: body
120
865
  };
121
866
  }
867
+ /** Создаёт единый контракт пагинированной операции. */
868
+ function pageOperation(id, read) {
869
+ return defineBuiltInOperation(id, read);
870
+ }
122
871
  /**
123
872
  * Перебор страниц списка.
124
873
  *
@@ -244,6 +993,10 @@ var BaseResource = class {
244
993
  constructor(http) {
245
994
  this.http = http;
246
995
  }
996
+ /** Выполняет операцию без возвращаемого значения и отбрасывает служебное тело ответа. */
997
+ voidOperation(operation, options) {
998
+ return this.http.execute(operation, options);
999
+ }
247
1000
  /**
248
1001
  * Собирает перебор страниц.
249
1002
  *
@@ -261,53 +1014,287 @@ var BaseResource = class {
261
1014
  });
262
1015
  }
263
1016
  /**
264
- * Собирает пару «загрузка страницы + перебор» из одного описания.
1017
+ * Собирает пару «загрузка страницы + перебор» из одного описания.
1018
+ *
1019
+ * Путь, параметры запроса и разбор ответа задаются один раз; `list` и `iterate`
1020
+ * строятся из них.
1021
+ *
1022
+ * @example
1023
+ * ```ts
1024
+ * #feed = this.paginated<Post, FeedParams>({
1025
+ * operation: POSTS_LIST,
1026
+ * path: () => '/api/posts',
1027
+ * query: (p) => ({ tab: p.tab, limit: p.limit }),
1028
+ * start: (p) => (p.cursor ? { cursor: p.cursor } : {}),
1029
+ * mode: PaginationMode.Cursor,
1030
+ * });
1031
+ * ```
1032
+ */
1033
+ paginated(spec) {
1034
+ const load = async (params, state, options = {}) => {
1035
+ const operation = typeof spec.operation === "function" ? spec.operation(params) : spec.operation;
1036
+ return this.http.execute(operation, {
1037
+ path: spec.path(params),
1038
+ query: withPageState(spec.query(params), state),
1039
+ ...options
1040
+ });
1041
+ };
1042
+ return {
1043
+ list: (params, options) => load(params, spec.start(params), options),
1044
+ iterate: (params, options = {}) => {
1045
+ const { maxPages, ...requestOptions } = options;
1046
+ return this.paginate(spec.mode, (state) => load(params, state, requestOptions), {
1047
+ ...maxPages !== void 0 ? { maxPages } : {},
1048
+ ...requestOptions.signal !== void 0 ? { signal: requestOptions.signal } : {},
1049
+ start: spec.start(params)
1050
+ });
1051
+ }
1052
+ };
1053
+ }
1054
+ };
1055
+ /** Добавляет позицию страницы в параметры запроса. */
1056
+ function withPageState(query, state) {
1057
+ return {
1058
+ ...query,
1059
+ ...state.cursor !== void 0 ? { cursor: state.cursor } : {},
1060
+ ...state.page !== void 0 ? { page: state.page } : {},
1061
+ ...state.offset !== void 0 ? { offset: state.offset } : {}
1062
+ };
1063
+ }
1064
+ //#endregion
1065
+ //#region src/resources/notifications.ts
1066
+ const NOTIFICATIONS_LIST = pageOperation("notifications.list", (body, request) => {
1067
+ const offset = Number(request.query?.offset ?? 0);
1068
+ const page = readOffsetPage(body, "notifications", Number.isFinite(offset) ? offset : 0);
1069
+ return {
1070
+ ...page,
1071
+ items: page.items.map(normalizeNotification)
1072
+ };
1073
+ });
1074
+ const NOTIFICATIONS_COUNT = defineBuiltInOperation("notifications.count", (body) => pickNumber(body, "count", 0));
1075
+ const markedCountOperation = (id) => defineBuiltInOperation(id, (body) => pickNumber(body, "markedCount", 0));
1076
+ const NOTIFICATIONS_MARK_READ = markedCountOperation("notifications.markRead");
1077
+ const NOTIFICATIONS_MARK_READ_BATCH = markedCountOperation("notifications.markReadBatch");
1078
+ const NOTIFICATIONS_MARK_ALL_READ = markedCountOperation("notifications.markAllRead");
1079
+ const NOTIFICATIONS_GET_SETTINGS = defineBuiltInOperation("notifications.getSettings", readSettings);
1080
+ const NOTIFICATIONS_UPDATE_SETTINGS = defineBuiltInOperation("notifications.updateSettings", readSettings);
1081
+ const NOTIFICATION_SETTING_KEYS = [
1082
+ "enabled",
1083
+ "sound",
1084
+ "follows",
1085
+ "wallPosts",
1086
+ "likes",
1087
+ "comments",
1088
+ "mentions"
1089
+ ];
1090
+ /**
1091
+ * Сколько идентификаторов уходит в одном запросе на отметку прочтения.
1092
+ *
1093
+ * Столько же отправляет сайт итд.com — значит на сервере, скорее всего, есть ограничение.
1094
+ */
1095
+ const READ_BATCH_SIZE = 20;
1096
+ /**
1097
+ * Читает настройки уведомлений.
1098
+ *
1099
+ * Сервер отдаёт плоский объект: `enabled`, `sound`, `follows`, `wallPosts`, `likes`,
1100
+ * `comments`, `mentions`. Отсутствующая настройка считается включённой — так же
1101
+ * ведёт себя сайт итд.com.
1102
+ */
1103
+ function readSettings(body) {
1104
+ const settings = {};
1105
+ for (const key of NOTIFICATION_SETTING_KEYS) settings[key] = pickBoolean(body, key, true);
1106
+ return settings;
1107
+ }
1108
+ /**
1109
+ * Уведомления: список, счётчик, отметки о прочтении, настройки.
1110
+ *
1111
+ * Доступна как `itd.notifications`. Все уведомления приведены к единой форме, поэтому
1112
+ * объекты отсюда и из потока событий можно складывать в один список.
1113
+ */
1114
+ var NotificationsResource = class extends BaseResource {
1115
+ /** Уведомления: `/api/notifications/`, пагинация по смещению. */
1116
+ #list = this.paginated({
1117
+ operation: NOTIFICATIONS_LIST,
1118
+ path: () => "/api/notifications/",
1119
+ query: (p) => ({ limit: p.limit }),
1120
+ start: (p) => ({ offset: p.offset ?? 0 }),
1121
+ mode: PaginationMode.Offset
1122
+ });
1123
+ /**
1124
+ * Загружает страницу уведомлений.
1125
+ *
1126
+ * Пагинация здесь основана на смещении.
1127
+ *
1128
+ * @example
1129
+ * ```ts
1130
+ * const page = await itd.notifications.list({ limit: 20 });
1131
+ * const next = await itd.notifications.list({ limit: 20, offset: page.nextOffset });
1132
+ * ```
1133
+ */
1134
+ list(params = {}, options = {}) {
1135
+ return this.#list.list(params, options);
1136
+ }
1137
+ /**
1138
+ * Перебирает уведомления.
1139
+ *
1140
+ * @example
1141
+ * ```ts
1142
+ * for await (const notification of itd.notifications.iterate()) {
1143
+ * console.log(formatNotificationText(notification));
1144
+ * }
1145
+ * ```
1146
+ */
1147
+ iterate(params = {}, options = {}) {
1148
+ return this.#list.iterate(params, options);
1149
+ }
1150
+ /** Загружает число непрочитанных уведомлений. */
1151
+ count(options = {}) {
1152
+ return this.http.execute(NOTIFICATIONS_COUNT, {
1153
+ path: "/api/notifications/count",
1154
+ ...options
1155
+ });
1156
+ }
1157
+ /**
1158
+ * Отмечает уведомление прочитанным.
1159
+ *
1160
+ * @returns сколько записей отметил сервер
1161
+ */
1162
+ markRead(notificationId, options = {}) {
1163
+ return this.http.execute(NOTIFICATIONS_MARK_READ, {
1164
+ path: `/api/notifications/${encodePathSegment(notificationId, "notificationId")}/read`,
1165
+ ...options
1166
+ });
1167
+ }
1168
+ /**
1169
+ * Отмечает прочитанными сразу несколько уведомлений.
265
1170
  *
266
- * Путь, параметры запроса и разбор ответа задаются один раз; `list` и `iterate`
267
- * строятся из них.
1171
+ * Список автоматически режется на части по 20 идентификаторов столько же отправляет
1172
+ * сайт итд.com, поэтому на сервере вероятен предел. Части уходят последовательно,
1173
+ * результат суммируется.
268
1174
  *
269
- * @example
270
- * ```ts
271
- * #feed = this.paginated<Post, FeedParams>({
272
- * operationId: 'posts.list',
273
- * path: () => '/api/posts',
274
- * query: (p) => ({ tab: p.tab, limit: p.limit }),
275
- * start: (p) => (p.cursor ? { cursor: p.cursor } : {}),
276
- * read: (body) => readCursorPage<Post>(body, 'posts'),
277
- * mode: PaginationMode.Cursor,
278
- * });
279
- * ```
1175
+ * @returns сколько записей отметил сервер суммарно
280
1176
  */
281
- paginated(spec) {
282
- const load = async (params, state, options = {}) => {
283
- const operationId = typeof spec.operationId === "function" ? spec.operationId(params) : spec.operationId;
284
- const body = await this.http.operation(operationId, {
285
- path: spec.path(params),
286
- query: withPageState(spec.query(params), state),
1177
+ async markReadBatch(ids, options = {}) {
1178
+ let marked = 0;
1179
+ for (let index = 0; index < ids.length; index += READ_BATCH_SIZE) {
1180
+ const chunk = ids.slice(index, index + READ_BATCH_SIZE);
1181
+ marked += await this.http.execute(NOTIFICATIONS_MARK_READ_BATCH, {
1182
+ path: "/api/notifications/read-batch",
1183
+ body: { ids: chunk },
287
1184
  ...options
288
1185
  });
289
- return spec.read(body, state);
290
- };
291
- return {
292
- list: (params, options) => load(params, spec.start(params), options),
293
- iterate: (params, options = {}) => {
294
- const { maxPages, ...requestOptions } = options;
295
- return this.paginate(spec.mode, (state) => load(params, state, requestOptions), {
296
- ...maxPages !== void 0 ? { maxPages } : {},
297
- ...requestOptions.signal !== void 0 ? { signal: requestOptions.signal } : {},
298
- start: spec.start(params)
299
- });
300
- }
301
- };
1186
+ }
1187
+ return marked;
1188
+ }
1189
+ /** Отмечает прочитанными все уведомления. */
1190
+ markAllRead(options = {}) {
1191
+ return this.http.execute(NOTIFICATIONS_MARK_ALL_READ, {
1192
+ path: "/api/notifications/read-all",
1193
+ ...options
1194
+ });
1195
+ }
1196
+ /** Загружает настройки уведомлений. */
1197
+ getSettings(options = {}) {
1198
+ return this.http.execute(NOTIFICATIONS_GET_SETTINGS, {
1199
+ path: "/api/notifications/settings",
1200
+ ...options
1201
+ });
1202
+ }
1203
+ /**
1204
+ * Обновляет настройки уведомлений.
1205
+ *
1206
+ * Отправляются только изменяемые поля, в том же виде, в каком сервер их возвращает.
1207
+ */
1208
+ async updateSettings(input, options = {}) {
1209
+ const payload = {};
1210
+ for (const key of NOTIFICATION_SETTING_KEYS) {
1211
+ const value = input[key];
1212
+ if (value !== void 0) payload[key] = value;
1213
+ }
1214
+ return this.http.execute(NOTIFICATIONS_UPDATE_SETTINGS, {
1215
+ path: "/api/notifications/settings",
1216
+ body: payload,
1217
+ ...options
1218
+ });
302
1219
  }
303
1220
  };
304
- /** Добавляет позицию страницы в параметры запроса. */
305
- function withPageState(query, state) {
1221
+ //#endregion
1222
+ //#region src/domain/time.ts
1223
+ const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
1224
+ /**
1225
+ * Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC.
1226
+ *
1227
+ * Строку другого вида возвращает нетронутой.
1228
+ *
1229
+ * @example
1230
+ * ```ts
1231
+ * utcStampToIso('2026-07-23 23:14:25'); // '2026-07-23T23:14:25Z'
1232
+ * utcStampToIso('2026-07-23T23:14:25Z'); // без изменений
1233
+ * ```
1234
+ */
1235
+ function utcStampToIso(value) {
1236
+ const match = typeof value === "string" ? NAIVE_STAMP.exec(value) : null;
1237
+ if (!match) return value;
1238
+ const iso = `${match[1]}T${match[2]}Z`;
1239
+ return Number.isFinite(Date.parse(iso)) ? iso : value;
1240
+ }
1241
+ /**
1242
+ * Разбирает дату API в объект `Date`.
1243
+ *
1244
+ * @returns `null`, если строки нет или она не разбирается
1245
+ *
1246
+ * @example
1247
+ * ```ts
1248
+ * const created = toDate(post.createdAt);
1249
+ * ```
1250
+ */
1251
+ function toDate(value) {
1252
+ if (!value) return null;
1253
+ const date = new Date(value);
1254
+ return Number.isFinite(date.getTime()) ? date : null;
1255
+ }
1256
+ //#endregion
1257
+ //#region src/resources/status.ts
1258
+ /** Приводит `last_checked` каждого сервиса к ISO. Остальное остаётся как прислал сервер. */
1259
+ function normalizeStatus(body) {
1260
+ if (!isRecord(body) || !Array.isArray(body.services)) return body;
306
1261
  return {
307
- ...query,
308
- ...state.cursor !== void 0 ? { cursor: state.cursor } : {},
309
- ...state.page !== void 0 ? { page: state.page } : {},
310
- ...state.offset !== void 0 ? { offset: state.offset } : {}
1262
+ ...body,
1263
+ services: body.services.map((service) => typeof service?.last_checked === "string" ? {
1264
+ ...service,
1265
+ last_checked: utcStampToIso(service.last_checked)
1266
+ } : service)
1267
+ };
1268
+ }
1269
+ /** API встроенного status feature. @internal */
1270
+ var StatusResource = class {
1271
+ #context;
1272
+ constructor(context) {
1273
+ this.#context = context;
1274
+ }
1275
+ get(options = {}) {
1276
+ return this.#context.request("get", {
1277
+ path: "/api/status",
1278
+ ...options
1279
+ });
1280
+ }
1281
+ };
1282
+ /** Встроенный модуль страницы состояния платформы. @internal */
1283
+ function createStatusFeature() {
1284
+ return {
1285
+ name: "status",
1286
+ services: [{
1287
+ name: STATUS_SERVICE,
1288
+ baseUrl: DEFAULT_STATUS_BASE_URL,
1289
+ auth: false
1290
+ }],
1291
+ operations: { get: {
1292
+ method: "GET",
1293
+ retrySafety: RetrySafety.Safe,
1294
+ service: STATUS_SERVICE,
1295
+ read: (body) => normalizeStatus(body)
1296
+ } },
1297
+ setup: (context) => ({ api: new StatusResource(context) })
311
1298
  };
312
1299
  }
313
1300
  //#endregion
@@ -470,6 +1457,13 @@ function resolveComment(input, allowReplyTo = false) {
470
1457
  }
471
1458
  //#endregion
472
1459
  //#region src/resources/comments.ts
1460
+ const COMMENTS_REPLIES = pageOperation("comments.replies", (body) => readPagedPage(body, "replies"));
1461
+ const COMMENTS_REPLY = passthroughOperation("comments.reply");
1462
+ const COMMENTS_UPDATE = passthroughOperation("comments.update");
1463
+ const COMMENTS_RESTORE = passthroughOperation("comments.restore");
1464
+ const COMMENTS_LIKE = passthroughOperation("comments.like");
1465
+ const COMMENTS_UNLIKE = passthroughOperation("comments.unlike");
1466
+ const COMMENTS_REMOVE = voidOperation("comments.remove");
473
1467
  /**
474
1468
  * Комментарии и ответы на них.
475
1469
  *
@@ -480,11 +1474,10 @@ var CommentsResource = class extends BaseResource {
480
1474
  #uploadFiles;
481
1475
  /** Ответы на комментарий: `/api/comments/{id}/replies`, постраничная пагинация. */
482
1476
  #replies = this.paginated({
483
- operationId: "comments.replies",
1477
+ operation: COMMENTS_REPLIES,
484
1478
  path: (p) => `/api/comments/${encodePathSegment(p.commentId, "commentId")}/replies`,
485
1479
  query: (p) => ({ limit: p.limit }),
486
1480
  start: (p) => p.page !== void 0 ? { page: p.page } : {},
487
- read: (body) => readPagedPage(body, "replies"),
488
1481
  mode: PaginationMode.Page
489
1482
  });
490
1483
  constructor(http, deps) {
@@ -523,7 +1516,7 @@ var CommentsResource = class extends BaseResource {
523
1516
  const existing = data.attachmentIds ?? [];
524
1517
  const files = data.files ?? [];
525
1518
  const attachmentIds = files.length > 0 ? [...existing, ...await this.#uploadFiles(files, options)] : existing;
526
- return this.http.operation("comments.reply", {
1519
+ return this.http.execute(COMMENTS_REPLY, {
527
1520
  path: `/api/comments/${encodePathSegment(commentId, "commentId")}/replies`,
528
1521
  body: {
529
1522
  content: data.content ?? "",
@@ -535,7 +1528,7 @@ var CommentsResource = class extends BaseResource {
535
1528
  }
536
1529
  /** Редактирует текст комментария. */
537
1530
  update(commentId, content, options = {}) {
538
- return this.http.operation("comments.update", {
1531
+ return this.http.execute(COMMENTS_UPDATE, {
539
1532
  path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
540
1533
  body: { content },
541
1534
  ...options
@@ -543,167 +1536,33 @@ var CommentsResource = class extends BaseResource {
543
1536
  }
544
1537
  /** Удаляет комментарий. Восстановить его можно через {@link restore}. */
545
1538
  remove(commentId, options = {}) {
546
- return this.http.operation("comments.remove", {
1539
+ return this.voidOperation(COMMENTS_REMOVE, {
547
1540
  path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
548
1541
  ...options
549
1542
  });
550
1543
  }
551
1544
  /** Восстанавливает удалённый комментарий. */
552
1545
  restore(commentId, options = {}) {
553
- return this.http.operation("comments.restore", {
1546
+ return this.http.execute(COMMENTS_RESTORE, {
554
1547
  path: `/api/comments/${encodePathSegment(commentId, "commentId")}/restore`,
555
1548
  ...options
556
1549
  });
557
1550
  }
558
1551
  /** Ставит реакцию на комментарий. */
559
1552
  like(commentId, options = {}) {
560
- return this.http.operation("comments.like", {
1553
+ return this.http.execute(COMMENTS_LIKE, {
561
1554
  path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
562
1555
  ...options
563
1556
  });
564
1557
  }
565
1558
  /** Убирает реакцию с комментария. */
566
1559
  unlike(commentId, options = {}) {
567
- return this.http.operation("comments.unlike", {
1560
+ return this.http.execute(COMMENTS_UNLIKE, {
568
1561
  path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
569
1562
  ...options
570
- });
571
- }
572
- };
573
- //#endregion
574
- //#region src/core/attachments/url-source.ts
575
- /** Убирает параметры MIME и приводит его к форме для сравнения. */
576
- function normalizeMimeType(contentType) {
577
- return contentType?.split(";", 1)[0]?.trim().toLowerCase() || void 0;
578
- }
579
- /** Достаёт имя файла из пути URL. */
580
- function filenameFromUrl(url) {
581
- const last = url.pathname.split("/").pop();
582
- if (!last) return void 0;
583
- try {
584
- return decodeURIComponent(last) || void 0;
585
- } catch {
586
- return last;
587
- }
588
- }
589
- /** Проверяет объявленный размер и возвращает его, если заголовок корректен. */
590
- function declaredSize(response, maxBytes, url) {
591
- const header = response.headers.get("content-length");
592
- if (header === null) return void 0;
593
- const size = Number(header);
594
- if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) return void 0;
595
- if (maxBytes !== void 0 && size > maxBytes) throw fileTooLarge(url, maxBytes, size);
596
- return size;
597
- }
598
- /** Получает HTTP-ответ источника и проверяет его статус. */
599
- async function fetchFile(target, options, context) {
600
- let requested;
601
- try {
602
- requested = new URL(target);
603
- } catch {
604
- throw new ItdConfigError(`«${target}» не разбирается как адрес`);
605
- }
606
- if (requested.protocol !== "http:" && requested.protocol !== "https:") throw new ItdConfigError(`вложение по адресу поддерживает только http и https, получено: ${requested.protocol}`);
607
- let response;
608
- try {
609
- response = await context.fetch(requested, { ...context.signal ? { signal: context.signal } : {} });
610
- } catch (error) {
611
- if (context.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
612
- throw new ItdFileError(`не удалось получить файл по адресу ${requested.href}`, {
613
- reason: ItdFileErrorReason.Network,
614
- url: requested.href,
615
- retryable: true,
616
- cause: error
617
- });
618
- }
619
- const finalUrl = response.url ? new URL(response.url) : requested;
620
- if (!response.ok) {
621
- await response.body?.cancel().catch(() => {});
622
- throw new ItdFileError(`источник ${finalUrl.href} ответил статусом ${response.status}`, {
623
- reason: ItdFileErrorReason.Http,
624
- url: finalUrl.href,
625
- status: response.status,
626
- retryable: response.status === 408 || response.status === 429 || response.status >= 500
627
- });
628
- }
629
- const { maxBytes } = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
630
- let size;
631
- try {
632
- size = declaredSize(response, maxBytes, finalUrl.href);
633
- } catch (error) {
634
- await response.body?.cancel().catch(() => {});
635
- throw error;
636
- }
637
- return {
638
- response,
639
- url: finalUrl,
640
- size
641
- };
642
- }
643
- /** Читает ответ с контролем размера до создания итогового `Blob`. */
644
- async function responseBlob(response, url, maxBytes, streamBufferBytes, signal) {
645
- if (!response.body) {
646
- const blob = await response.blob();
647
- if (maxBytes !== void 0 && blob.size > maxBytes) throw fileTooLarge(url, maxBytes, blob.size);
648
- return blob;
649
- }
650
- const chunks = [];
651
- const reader = boundedFileStream(response.body, {
652
- ...maxBytes !== void 0 ? { maxBytes } : {},
653
- streamBufferBytes,
654
- ...signal ? { signal } : {},
655
- url,
656
- retryableRead: true
657
- }).getReader();
658
- try {
659
- for (;;) {
660
- const next = await reader.read();
661
- if (next.done) break;
662
- chunks.push(next.value);
663
- }
664
- } finally {
665
- reader.releaseLock();
666
- }
667
- return new Blob(chunks.map((chunk) => Uint8Array.from(chunk).buffer));
668
- }
669
- /** Скачивает файл целиком с ограничением размера. @internal */
670
- async function downloadFile(target, options, context) {
671
- const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
672
- const { response, url } = await fetchFile(target, options, context);
673
- const blob = await responseBlob(response, url.href, resolved.maxBytes, resolved.streamBufferBytes, context.signal);
674
- const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
675
- const filename = options.filename ?? filenameFromUrl(url);
676
- return {
677
- file: new Blob([blob], { type: contentType ?? "" }),
678
- ...filename ? { filename } : {},
679
- ...contentType ? { contentType } : {}
680
- };
681
- }
682
- /** Открывает HTTP-ответ как ограниченный поток. @internal */
683
- async function openUrlFile(target, options, context) {
684
- const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
685
- const { response, url, size } = await fetchFile(target, options, context);
686
- if (!response.body) throw new ItdFileError(`источник ${url.href} не предоставил потоковое тело`, {
687
- reason: ItdFileErrorReason.StreamUnavailable,
688
- url: url.href
689
- });
690
- const stream = boundedFileStream(response.body, {
691
- ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
692
- streamBufferBytes: resolved.streamBufferBytes,
693
- ...context.signal ? { signal: context.signal } : {},
694
- url: url.href,
695
- retryableRead: true
696
- });
697
- const filename = options.filename ?? filenameFromUrl(url);
698
- const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
699
- return {
700
- stream,
701
- ...filename ? { filename } : {},
702
- ...contentType ? { contentType } : {},
703
- ...size !== void 0 ? { size } : {},
704
- close: () => stream.cancel().catch(() => {})
705
- };
706
- }
1563
+ });
1564
+ }
1565
+ };
707
1566
  //#endregion
708
1567
  //#region src/core/multipart.ts
709
1568
  const encoder = new TextEncoder();
@@ -841,12 +1700,17 @@ function assertAllowedMime(mimeType, filename) {
841
1700
  if (!mimeType) throw new ItdConfigError(`Не удалось определить тип файла${filename ? ` «${filename}»` : ""}. Укажите его явно: { file, filename, contentType }.`);
842
1701
  if (!isAllowedMime(mimeType)) throw new ItdConfigError(`Тип «${mimeType}» не поддерживается загрузкой. Допустимые: ${ALLOWED_MIME_TYPES.join(", ")}.`);
843
1702
  }
1703
+ //#endregion
1704
+ //#region src/resources/files.ts
1705
+ const FILES_UPLOAD = passthroughOperation("files.upload");
1706
+ const FILES_GET = passthroughOperation("files.get");
1707
+ const FILES_REMOVE = voidOperation("files.remove");
844
1708
  /** Файлы и медиа. */
845
1709
  var FilesResource = class extends BaseResource {
846
- #fetch;
1710
+ #files;
847
1711
  constructor(http, deps) {
848
1712
  super(http);
849
- this.#fetch = deps.fetch;
1713
+ this.#files = deps.files;
850
1714
  }
851
1715
  /**
852
1716
  * Загружает файл и возвращает его идентификатор.
@@ -856,7 +1720,7 @@ var FilesResource = class extends BaseResource {
856
1720
  */
857
1721
  upload(input, uploadOptions = {}, requestOptions = {}) {
858
1722
  const bodyFactory = this.#createBodyFactory(input, uploadOptions);
859
- return this.http.operation("files.upload", {
1723
+ return this.http.execute(FILES_UPLOAD, {
860
1724
  path: "/api/files/upload",
861
1725
  bodyFactory,
862
1726
  ...requestOptions,
@@ -875,14 +1739,14 @@ var FilesResource = class extends BaseResource {
875
1739
  * Для ещё не прикреплённого файла сервер может ответить `404`.
876
1740
  */
877
1741
  get(fileId, options = {}) {
878
- return this.http.operation("files.get", {
1742
+ return this.http.execute(FILES_GET, {
879
1743
  path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
880
1744
  ...options
881
1745
  });
882
1746
  }
883
1747
  /** Удаляет загруженный файл. */
884
1748
  remove(fileId, options = {}) {
885
- return this.http.operation("files.remove", {
1749
+ return this.voidOperation(FILES_REMOVE, {
886
1750
  path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
887
1751
  ...options
888
1752
  });
@@ -892,140 +1756,70 @@ var FilesResource = class extends BaseResource {
892
1756
  * заново, поэтому каждая транспортная попытка получает непрочитанное тело.
893
1757
  */
894
1758
  #createBodyFactory(input, options) {
895
- const streamMode = this.#isStreamInput(input);
1759
+ const streamMode = this.#files.mode(input) === FileTransferMode.Stream;
896
1760
  let buffered;
897
1761
  return async ({ signal, attempt }) => {
898
- const context = {
899
- fetch: this.#fetch,
1762
+ let prepared;
1763
+ if (streamMode) prepared = await this.#files.resolve(input, options, {
900
1764
  signal,
901
1765
  attempt
902
- };
903
- let prepared;
904
- try {
905
- if (streamMode) prepared = await this.#prepareStream(input, options, context);
906
- else {
907
- buffered ??= this.#prepareBuffer(input, options, context).catch((error) => {
908
- buffered = void 0;
909
- throw error;
910
- });
911
- prepared = await buffered;
912
- }
913
- } catch (error) {
914
- if (error instanceof ItdError || signal.aborted) throw error;
915
- throw new ItdFileError("не удалось получить содержимое вложения", {
916
- reason: ItdFileErrorReason.Read,
917
- retryable: true,
918
- cause: error
1766
+ });
1767
+ else {
1768
+ buffered ??= this.#files.resolve(input, options, {
1769
+ signal,
1770
+ attempt
1771
+ }).then((source) => source).catch((error) => {
1772
+ buffered = void 0;
1773
+ throw error;
919
1774
  });
1775
+ prepared = await buffered;
920
1776
  }
921
- if (prepared.mode === "buffer") {
1777
+ if (prepared.mode === FileTransferMode.Buffer) {
1778
+ const filename = this.#filename(prepared, options);
1779
+ const contentType = prepared.contentType ?? mimeFromFilename(filename);
1780
+ if (options.validateMime !== false) assertAllowedMime(contentType, filename);
1781
+ const blob = !contentType || prepared.blob.type === contentType ? prepared.blob : new Blob([prepared.blob], { type: contentType });
922
1782
  const form = new FormData();
923
- form.set("file", prepared.blob, prepared.filename);
1783
+ form.set("file", blob, filename);
924
1784
  return { body: form };
925
1785
  }
926
- const multipart = createMultipartFileBody(prepared);
927
- return {
928
- body: multipart.body,
929
- headers: { "Content-Type": multipart.contentType },
930
- cleanup: async () => {
931
- await multipart.cancel();
932
- await prepared.close?.();
933
- }
934
- };
935
- };
936
- }
937
- /** Определяет режим без чтения источника. */
938
- #isStreamInput(input) {
939
- if (typeof input !== "object" || input === null) return false;
940
- if ("open" in input) return true;
941
- if ("url" in input) return resolveFileStreamOptions(input).mode === FileTransferMode.Stream;
942
- return false;
943
- }
944
- /** Получает и проверяет буферный источник. */
945
- async #prepareBuffer(input, options, context) {
946
- const content = await this.#resolveBuffer(input, context);
947
- const filename = options.filename ?? content.filename ?? this.#nameFromMime(options.contentType ?? content.contentType);
948
- const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
949
- if (options.validateMime !== false) assertAllowedMime(contentType, filename);
950
- const blob = isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
951
- const limits = resolveFileStreamOptions(options);
952
- if (limits.maxBytes !== void 0 && blob.size > limits.maxBytes) throw new ItdFileError(`файл больше предела в ${limits.maxBytes} байт: ${blob.size}`, {
953
- reason: ItdFileErrorReason.TooLarge,
954
- limit: limits.maxBytes,
955
- actual: blob.size
956
- });
957
- return {
958
- mode: "buffer",
959
- blob,
960
- filename
961
- };
962
- }
963
- /** Открывает и проверяет потоковый источник. */
964
- async #prepareStream(input, options, context) {
965
- let opened;
966
- if (typeof input === "object" && input !== null && "open" in input) opened = await input.open(context);
967
- else if (typeof input === "object" && input !== null && "url" in input) {
968
- const { url, ...urlOptions } = input;
969
- opened = await openUrlFile(url, urlOptions, context);
970
- } else throw new ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
971
- if (!opened || typeof ReadableStream === "undefined" || !(opened.stream instanceof ReadableStream)) {
972
- await opened?.close?.();
973
- throw new ItdConfigError("потоковый источник должен вернуть { stream: ReadableStream }");
974
- }
975
- try {
976
- const filename = options.filename ?? opened.filename ?? this.#nameFromMime(options.contentType ?? opened.contentType);
977
- const contentType = normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
978
- if (options.validateMime !== false) assertAllowedMime(contentType, filename);
979
- const limits = resolveFileStreamOptions({
980
- mode: FileTransferMode.Stream,
981
- ...options.maxBytes !== void 0 ? { maxBytes: options.maxBytes } : {},
982
- ...options.streamBufferBytes !== void 0 ? { streamBufferBytes: options.streamBufferBytes } : {}
983
- });
984
- if (limits.maxBytes !== void 0 && opened.size !== void 0 && opened.size > limits.maxBytes) throw new ItdFileError(`файл больше предела в ${limits.maxBytes} байт: ${opened.size}`, {
985
- reason: ItdFileErrorReason.TooLarge,
986
- limit: limits.maxBytes,
987
- actual: opened.size
988
- });
989
- return {
990
- mode: "stream",
991
- stream: isBoundedFileStream(opened.stream) && options.maxBytes === void 0 && options.streamBufferBytes === void 0 ? opened.stream : boundedFileStream(opened.stream, {
992
- ...limits.maxBytes !== void 0 ? { maxBytes: limits.maxBytes } : {},
993
- streamBufferBytes: limits.streamBufferBytes,
994
- signal: context.signal
995
- }),
996
- filename,
997
- contentType: contentType ?? "",
998
- ...opened.close ? { close: opened.close } : {}
999
- };
1000
- } catch (error) {
1001
- await opened.close?.();
1002
- throw error;
1003
- }
1004
- }
1005
- /** Разрешает только буферные формы. */
1006
- #resolveBuffer(input, context) {
1007
- if (input instanceof ArrayBuffer || ArrayBuffer.isView(input) || isBlob(input)) return {
1008
- file: input,
1009
- ...isFile(input) ? { filename: input.name } : {}
1786
+ let multipart;
1787
+ try {
1788
+ const filename = this.#filename(prepared, options);
1789
+ const contentType = prepared.contentType ?? mimeFromFilename(filename);
1790
+ if (options.validateMime !== false) assertAllowedMime(contentType, filename);
1791
+ multipart = createMultipartFileBody({
1792
+ stream: prepared.stream,
1793
+ filename,
1794
+ contentType: contentType ?? ""
1795
+ });
1796
+ return {
1797
+ body: multipart.body,
1798
+ headers: { "Content-Type": multipart.contentType },
1799
+ cleanup: async () => {
1800
+ await multipart?.cancel();
1801
+ await prepared.close();
1802
+ }
1803
+ };
1804
+ } catch (error) {
1805
+ await prepared.close();
1806
+ throw error;
1807
+ }
1010
1808
  };
1011
- if (typeof input !== "object" || input === null) throw new ItdConfigError(`вложение задано значением типа ${typeof input}; ожидается бинарное значение или объект { file }, { url }, { load }, { open }. Для файла на диске используйте fromPath('./photo.jpg') из itd-api/node`);
1012
- if ("load" in input) return input.load(context);
1013
- if ("url" in input) {
1014
- const { url, ...urlOptions } = input;
1015
- return downloadFile(url, urlOptions, context);
1016
- }
1017
- if ("file" in input) return input;
1018
- if ("open" in input) throw new ItdConfigError("потоковый источник нельзя использовать как буферный");
1019
- throw new ItdConfigError("вложение не распознано: ожидается { file }, { url }, { load } или { open }");
1020
1809
  }
1021
1810
  /** Подбирает непустое имя, обязательное для multipart. */
1022
- #nameFromMime(contentType) {
1023
- const extension = normalizeMimeType(contentType)?.split("/")[1];
1811
+ #filename(source, options) {
1812
+ if (options.filename) return options.filename;
1813
+ if (source.filename) return source.filename;
1814
+ const extension = options.contentType?.split(";", 1)[0]?.trim().split("/")[1];
1024
1815
  return extension ? `file.${extension}` : "file";
1025
1816
  }
1026
1817
  };
1027
1818
  //#endregion
1028
1819
  //#region src/resources/hashtags.ts
1820
+ const HASHTAG_POSTS = pageOperation("hashtags.posts", (body) => readCursorPage(body, "posts"));
1821
+ const HASHTAGS_SEARCH = defineBuiltInOperation("hashtags.search", (body) => pickArray(body, "hashtags"));
1822
+ const HASHTAGS_TRENDING = defineBuiltInOperation("hashtags.trending", (body) => pickArray(body, "hashtags"));
1029
1823
  /**
1030
1824
  * Хэштеги.
1031
1825
  *
@@ -1034,11 +1828,10 @@ var FilesResource = class extends BaseResource {
1034
1828
  var HashtagsResource = class extends BaseResource {
1035
1829
  /** Посты по хэштегу: `/api/hashtags/{tag}/posts`, курсорная пагинация. */
1036
1830
  #posts = this.paginated({
1037
- operationId: "hashtags.posts",
1831
+ operation: HASHTAG_POSTS,
1038
1832
  path: (p) => `/api/hashtags/${encodePathSegment(p.tag, "tag")}/posts`,
1039
1833
  query: (p) => ({ limit: p.limit }),
1040
1834
  start: (p) => p.cursor ? { cursor: p.cursor } : {},
1041
- read: (body) => readCursorPage(body, "posts"),
1042
1835
  mode: PaginationMode.Cursor
1043
1836
  });
1044
1837
  /**
@@ -1046,8 +1839,8 @@ var HashtagsResource = class extends BaseResource {
1046
1839
  *
1047
1840
  * Без строки запроса возвращает общий список.
1048
1841
  */
1049
- async search(query, params = {}, options = {}) {
1050
- const body = await this.http.operation("hashtags.search", {
1842
+ search(query, params = {}, options = {}) {
1843
+ return this.http.execute(HASHTAGS_SEARCH, {
1051
1844
  path: "/api/hashtags",
1052
1845
  query: {
1053
1846
  q: query,
@@ -1055,16 +1848,14 @@ var HashtagsResource = class extends BaseResource {
1055
1848
  },
1056
1849
  ...options
1057
1850
  });
1058
- return pickArray(body, "hashtags");
1059
1851
  }
1060
1852
  /** Загружает трендовые хэштеги. */
1061
- async trending(params = {}, options = {}) {
1062
- const body = await this.http.operation("hashtags.trending", {
1853
+ trending(params = {}, options = {}) {
1854
+ return this.http.execute(HASHTAGS_TRENDING, {
1063
1855
  path: "/api/hashtags/trending",
1064
1856
  query: { limit: params.limit },
1065
1857
  ...options
1066
1858
  });
1067
- return pickArray(body, "hashtags");
1068
1859
  }
1069
1860
  /**
1070
1861
  * Загружает страницу постов по хэштегу.
@@ -1087,212 +1878,23 @@ var HashtagsResource = class extends BaseResource {
1087
1878
  }
1088
1879
  };
1089
1880
  //#endregion
1090
- //#region src/resources/notifications.ts
1091
- const NOTIFICATION_SETTING_KEYS = [
1092
- "enabled",
1093
- "sound",
1094
- "follows",
1095
- "wallPosts",
1096
- "likes",
1097
- "comments",
1098
- "mentions"
1099
- ];
1100
- /**
1101
- * Сколько идентификаторов уходит в одном запросе на отметку прочтения.
1102
- *
1103
- * Столько же отправляет сайт итд.com — значит на сервере, скорее всего, есть ограничение.
1104
- */
1105
- const READ_BATCH_SIZE = 20;
1106
- /**
1107
- * Читает настройки уведомлений.
1108
- *
1109
- * Сервер отдаёт плоский объект: `enabled`, `sound`, `follows`, `wallPosts`, `likes`,
1110
- * `comments`, `mentions`. Отсутствующая настройка считается включённой — так же
1111
- * ведёт себя сайт итд.com.
1112
- */
1113
- function readSettings(body) {
1114
- const settings = {};
1115
- for (const key of NOTIFICATION_SETTING_KEYS) settings[key] = pickBoolean(body, key, true);
1116
- return settings;
1117
- }
1118
- /**
1119
- * Уведомления: список, счётчик, отметки о прочтении, настройки.
1120
- *
1121
- * Доступна как `itd.notifications`. Все уведомления приведены к единой форме, поэтому
1122
- * объекты отсюда и из потока событий можно складывать в один список.
1123
- */
1124
- var NotificationsResource = class extends BaseResource {
1125
- /** Уведомления: `/api/notifications/`, пагинация по смещению. */
1126
- #list = this.paginated({
1127
- operationId: "notifications.list",
1128
- path: () => "/api/notifications/",
1129
- query: (p) => ({ limit: p.limit }),
1130
- start: (p) => ({ offset: p.offset ?? 0 }),
1131
- read: (body, state) => {
1132
- const page = readOffsetPage(body, "notifications", state.offset ?? 0);
1133
- return {
1134
- ...page,
1135
- items: page.items.map(normalizeNotification)
1136
- };
1137
- },
1138
- mode: PaginationMode.Offset
1139
- });
1140
- /**
1141
- * Загружает страницу уведомлений.
1142
- *
1143
- * Пагинация здесь основана на смещении.
1144
- *
1145
- * @example
1146
- * ```ts
1147
- * const page = await itd.notifications.list({ limit: 20 });
1148
- * const next = await itd.notifications.list({ limit: 20, offset: page.nextOffset });
1149
- * ```
1150
- */
1151
- list(params = {}, options = {}) {
1152
- return this.#list.list(params, options);
1153
- }
1154
- /**
1155
- * Перебирает уведомления.
1156
- *
1157
- * @example
1158
- * ```ts
1159
- * for await (const notification of itd.notifications.iterate()) {
1160
- * console.log(formatNotificationText(notification));
1161
- * }
1162
- * ```
1163
- */
1164
- iterate(params = {}, options = {}) {
1165
- return this.#list.iterate(params, options);
1166
- }
1167
- /** Загружает число непрочитанных уведомлений. */
1168
- async count(options = {}) {
1169
- const body = await this.http.operation("notifications.count", {
1170
- path: "/api/notifications/count",
1171
- ...options
1172
- });
1173
- return pickNumber(body, "count", 0);
1174
- }
1175
- /**
1176
- * Отмечает уведомление прочитанным.
1177
- *
1178
- * @returns сколько записей отметил сервер
1179
- */
1180
- async markRead(notificationId, options = {}) {
1181
- const body = await this.http.operation("notifications.markRead", {
1182
- path: `/api/notifications/${encodePathSegment(notificationId, "notificationId")}/read`,
1183
- ...options
1184
- });
1185
- return pickNumber(body, "markedCount", 0);
1186
- }
1187
- /**
1188
- * Отмечает прочитанными сразу несколько уведомлений.
1189
- *
1190
- * Список автоматически режется на части по 20 идентификаторов — столько же отправляет
1191
- * сайт итд.com, поэтому на сервере вероятен предел. Части уходят последовательно,
1192
- * результат суммируется.
1193
- *
1194
- * @returns сколько записей отметил сервер суммарно
1195
- */
1196
- async markReadBatch(ids, options = {}) {
1197
- let marked = 0;
1198
- for (let index = 0; index < ids.length; index += READ_BATCH_SIZE) {
1199
- const chunk = ids.slice(index, index + READ_BATCH_SIZE);
1200
- const body = await this.http.operation("notifications.markReadBatch", {
1201
- path: "/api/notifications/read-batch",
1202
- body: { ids: chunk },
1203
- ...options
1204
- });
1205
- marked += pickNumber(body, "markedCount", 0);
1206
- }
1207
- return marked;
1208
- }
1209
- /** Отмечает прочитанными все уведомления. */
1210
- async markAllRead(options = {}) {
1211
- const body = await this.http.operation("notifications.markAllRead", {
1212
- path: "/api/notifications/read-all",
1213
- ...options
1214
- });
1215
- return pickNumber(body, "markedCount", 0);
1216
- }
1217
- /** Загружает настройки уведомлений. */
1218
- async getSettings(options = {}) {
1219
- return readSettings(await this.http.operation("notifications.getSettings", {
1220
- path: "/api/notifications/settings",
1221
- ...options
1222
- }));
1223
- }
1224
- /**
1225
- * Обновляет настройки уведомлений.
1226
- *
1227
- * Отправляются только изменяемые поля, в том же виде, в каком сервер их возвращает.
1228
- */
1229
- async updateSettings(input, options = {}) {
1230
- const payload = {};
1231
- for (const key of NOTIFICATION_SETTING_KEYS) {
1232
- const value = input[key];
1233
- if (value !== void 0) payload[key] = value;
1234
- }
1235
- return readSettings(await this.http.operation("notifications.updateSettings", {
1236
- path: "/api/notifications/settings",
1237
- body: payload,
1238
- ...options
1239
- }));
1240
- }
1241
- };
1242
- //#endregion
1243
- //#region src/domain/time.ts
1244
- const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
1245
- /**
1246
- * Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC.
1247
- *
1248
- * Строку другого вида возвращает нетронутой.
1249
- *
1250
- * @example
1251
- * ```ts
1252
- * utcStampToIso('2026-07-23 23:14:25'); // '2026-07-23T23:14:25Z'
1253
- * utcStampToIso('2026-07-23T23:14:25Z'); // без изменений
1254
- * ```
1255
- */
1256
- function utcStampToIso(value) {
1257
- const match = typeof value === "string" ? NAIVE_STAMP.exec(value) : null;
1258
- if (!match) return value;
1259
- const iso = `${match[1]}T${match[2]}Z`;
1260
- return Number.isFinite(Date.parse(iso)) ? iso : value;
1261
- }
1262
- /**
1263
- * Разбирает дату API в объект `Date`.
1264
- *
1265
- * @returns `null`, если строки нет или она не разбирается
1266
- *
1267
- * @example
1268
- * ```ts
1269
- * const created = toDate(post.createdAt);
1270
- * ```
1271
- */
1272
- function toDate(value) {
1273
- if (!value) return null;
1274
- const date = new Date(value);
1275
- return Number.isFinite(date.getTime()) ? date : null;
1276
- }
1277
- //#endregion
1278
1881
  //#region src/resources/platform.ts
1279
- /** Приводит `last_checked` каждого сервиса к ISO. Остальное остаётся как прислал сервер. */
1280
- function normalizeStatus(body) {
1281
- if (!isRecord(body) || !Array.isArray(body.services)) return body;
1282
- return {
1283
- ...body,
1284
- services: body.services.map((service) => typeof service?.last_checked === "string" ? {
1285
- ...service,
1286
- last_checked: utcStampToIso(service.last_checked)
1287
- } : service)
1288
- };
1289
- }
1882
+ const PLATFORM_CHANGELOG = defineBuiltInOperation("platform.changelog", (body) => Array.isArray(body) ? body : []);
1883
+ const PLATFORM_ANNOUNCEMENTS = defineBuiltInOperation("platform.announcements", (body) => pickArray(body, "announcements"));
1884
+ const PLATFORM_VERSION = passthroughOperation("platform.version");
1885
+ const PLATFORM_PORTAL = passthroughOperation("platform.portal");
1290
1886
  /**
1291
1887
  * Сведения о платформе: версии приложений, изменения, анонсы, баннер события.
1292
1888
  *
1293
1889
  * Доступна как `itd.platform`.
1294
1890
  */
1295
1891
  var PlatformResource = class extends BaseResource {
1892
+ #status;
1893
+ /** @internal */
1894
+ constructor(http, status) {
1895
+ super(http);
1896
+ this.#status = status;
1897
+ }
1296
1898
  /**
1297
1899
  * Загружает минимальные и актуальные версии клиентских приложений.
1298
1900
  *
@@ -1305,31 +1907,29 @@ var PlatformResource = class extends BaseResource {
1305
1907
  * ```
1306
1908
  */
1307
1909
  version(options = {}) {
1308
- return this.http.operation("platform.version", {
1910
+ return this.http.execute(PLATFORM_VERSION, {
1309
1911
  path: "/api/platform/version",
1310
1912
  skipAuth: true,
1311
1913
  ...options
1312
1914
  });
1313
1915
  }
1314
1916
  /** Загружает журнал изменений. */
1315
- async changelog(options = {}) {
1316
- const body = await this.http.operation("platform.changelog", {
1917
+ changelog(options = {}) {
1918
+ return this.http.execute(PLATFORM_CHANGELOG, {
1317
1919
  path: "/api/platform/changelog",
1318
1920
  ...options
1319
1921
  });
1320
- return Array.isArray(body) ? body : [];
1321
1922
  }
1322
1923
  /** Загружает анонсы платформы. */
1323
- async announcements(options = {}) {
1324
- const body = await this.http.operation("platform.announcements", {
1924
+ announcements(options = {}) {
1925
+ return this.http.execute(PLATFORM_ANNOUNCEMENTS, {
1325
1926
  path: "/api/platform/announcements",
1326
1927
  ...options
1327
1928
  });
1328
- return pickArray(body, "announcements");
1329
1929
  }
1330
1930
  /** Загружает баннер текущего события — виджет «портал». */
1331
1931
  portal(options = {}) {
1332
- return this.http.operation("platform.portal", {
1932
+ return this.http.execute(PLATFORM_PORTAL, {
1333
1933
  path: "/api/v1/portal",
1334
1934
  ...options
1335
1935
  });
@@ -1351,11 +1951,7 @@ var PlatformResource = class extends BaseResource {
1351
1951
  * ```
1352
1952
  */
1353
1953
  async status(options = {}) {
1354
- return normalizeStatus(await this.http.operation("platform.status", {
1355
- service: STATUS_SERVICE,
1356
- path: "/api/status",
1357
- ...options
1358
- }));
1954
+ return this.#status.get(options);
1359
1955
  }
1360
1956
  };
1361
1957
  //#endregion
@@ -2050,7 +2646,7 @@ function autoSpans(text, options = {}) {
2050
2646
  });
2051
2647
  }
2052
2648
  if (includeLinks) spans.push(...links);
2053
- if (includeHashtags || includeMentions) for (const match of text.matchAll(/#([\p{L}\p{M}\p{N}_]+)|@([a-z\d][a-z\d._-]*)/giu)) {
2649
+ if (includeHashtags || includeMentions) for (const match of text.matchAll(/#([\p{L}\p{M}\p{N}_]+)|@([a-z\d](?:[a-z\d_]*[a-z\d])?)/giu)) {
2054
2650
  if (match.index === void 0) continue;
2055
2651
  const offset = match.index;
2056
2652
  const previous = previousCodePoint(text, offset);
@@ -2058,7 +2654,7 @@ function autoSpans(text, options = {}) {
2058
2654
  const end = offset + match[0].length;
2059
2655
  if (links.some((span) => overlaps(span, offset, end))) continue;
2060
2656
  const hashtag = match[1];
2061
- const username = match[2]?.replace(/[._-]+$/u, "");
2657
+ const username = match[2];
2062
2658
  if (hashtag !== void 0 && includeHashtags) spans.push({
2063
2659
  type: SpanType.Hashtag,
2064
2660
  offset,
@@ -2451,6 +3047,24 @@ function resolvePostUpdate(input) {
2451
3047
  }
2452
3048
  //#endregion
2453
3049
  //#region src/resources/posts.ts
3050
+ const POSTS_LIST = pageOperation("posts.list", (body) => readCursorPage(body, "posts"));
3051
+ const POSTS_BY_USER = pageOperation("posts.byUser", (body) => readCursorPage(body, "posts"));
3052
+ const POSTS_LIKED_BY_USER = pageOperation("posts.likedByUser", (body) => readCursorPage(body, "posts"));
3053
+ const POSTS_COMMENTS = pageOperation("posts.comments", (body) => readFlatCursorPage(body, "comments"));
3054
+ const POSTS_STATS = defineBuiltInOperation("posts.stats", (body) => pickArray(body, "posts"));
3055
+ const POSTS_CREATE = passthroughOperation("posts.create");
3056
+ const POSTS_GET = passthroughOperation("posts.get");
3057
+ const POSTS_UPDATE = passthroughOperation("posts.update");
3058
+ const POSTS_RESTORE = passthroughOperation("posts.restore");
3059
+ const POSTS_LIKE = passthroughOperation("posts.like");
3060
+ const POSTS_UNLIKE = passthroughOperation("posts.unlike");
3061
+ const POSTS_REPOST = passthroughOperation("posts.repost");
3062
+ const POSTS_PIN = passthroughOperation("posts.pin");
3063
+ const POSTS_UNPIN = passthroughOperation("posts.unpin");
3064
+ const POSTS_VOTE = passthroughOperation("posts.vote");
3065
+ const POSTS_COMMENT = passthroughOperation("posts.comment");
3066
+ const POSTS_REMOVE = voidOperation("posts.remove");
3067
+ const POSTS_UNREPOST = voidOperation("posts.unrepost");
2454
3068
  /** Курсорная позиция из параметров: если курсор задан — с него, иначе с начала. */
2455
3069
  function cursorStart(params) {
2456
3070
  return params.cursor ? { cursor: params.cursor } : {};
@@ -2464,19 +3078,18 @@ var PostsResource = class extends BaseResource {
2464
3078
  #uploadFiles;
2465
3079
  /** Лента: `/api/posts`, курсорная пагинация. */
2466
3080
  #feed = this.paginated({
2467
- operationId: "posts.list",
3081
+ operation: POSTS_LIST,
2468
3082
  path: () => "/api/posts",
2469
3083
  query: (p) => ({
2470
3084
  tab: p.tab,
2471
3085
  limit: p.limit
2472
3086
  }),
2473
3087
  start: cursorStart,
2474
- read: (body) => readCursorPage(body, "posts"),
2475
3088
  mode: PaginationMode.Cursor
2476
3089
  });
2477
3090
  /** Стена пользователя: `/api/posts/user/{user}`. */
2478
3091
  #wall = this.paginated({
2479
- operationId: "posts.byUser",
3092
+ operation: POSTS_BY_USER,
2480
3093
  path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}`,
2481
3094
  query: (p) => ({
2482
3095
  limit: p.limit,
@@ -2484,28 +3097,25 @@ var PostsResource = class extends BaseResource {
2484
3097
  pinnedPostId: p.pinnedPostId
2485
3098
  }),
2486
3099
  start: cursorStart,
2487
- read: (body) => readCursorPage(body, "posts"),
2488
3100
  mode: PaginationMode.Cursor
2489
3101
  });
2490
3102
  /** Понравившиеся посты пользователя: `/api/posts/user/{user}/liked`. */
2491
3103
  #liked = this.paginated({
2492
- operationId: "posts.likedByUser",
3104
+ operation: POSTS_LIKED_BY_USER,
2493
3105
  path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}/liked`,
2494
3106
  query: (p) => ({ limit: p.limit }),
2495
3107
  start: cursorStart,
2496
- read: (body) => readCursorPage(body, "posts"),
2497
3108
  mode: PaginationMode.Cursor
2498
3109
  });
2499
3110
  /** Комментарии к посту: курсор лежит рядом со списком, поэтому свой reader. */
2500
3111
  #comments = this.paginated({
2501
- operationId: "posts.comments",
3112
+ operation: POSTS_COMMENTS,
2502
3113
  path: (p) => `/api/posts/${encodePathSegment(p.postId, "postId")}/comments`,
2503
3114
  query: (p) => ({
2504
3115
  limit: p.limit,
2505
3116
  sort: p.sort
2506
3117
  }),
2507
3118
  start: cursorStart,
2508
- read: (body) => readFlatCursorPage(body, "comments"),
2509
3119
  mode: PaginationMode.Cursor
2510
3120
  });
2511
3121
  constructor(http, deps) {
@@ -2552,7 +3162,7 @@ var PostsResource = class extends BaseResource {
2552
3162
  async create(input, options = {}) {
2553
3163
  const data = resolvePost(input);
2554
3164
  const attachmentIds = await this.#collectAttachments(data, options);
2555
- return this.http.operation("posts.create", {
3165
+ return this.http.execute(POSTS_CREATE, {
2556
3166
  path: "/api/posts",
2557
3167
  body: {
2558
3168
  content: data.content ?? "",
@@ -2570,7 +3180,7 @@ var PostsResource = class extends BaseResource {
2570
3180
  * В отличие от списков, здесь у поста заполнено поле `comments`.
2571
3181
  */
2572
3182
  get(postId, options = {}) {
2573
- return this.http.operation("posts.get", {
3183
+ return this.http.execute(POSTS_GET, {
2574
3184
  path: `/api/posts/${encodePathSegment(postId, "postId")}`,
2575
3185
  ...options
2576
3186
  });
@@ -2584,7 +3194,7 @@ var PostsResource = class extends BaseResource {
2584
3194
  */
2585
3195
  update(postId, input, options = {}) {
2586
3196
  const data = resolvePostUpdate(input);
2587
- return this.http.operation("posts.update", {
3197
+ return this.http.execute(POSTS_UPDATE, {
2588
3198
  path: `/api/posts/${encodePathSegment(postId, "postId")}`,
2589
3199
  body: {
2590
3200
  content: data.content,
@@ -2595,28 +3205,28 @@ var PostsResource = class extends BaseResource {
2595
3205
  }
2596
3206
  /** Удаляет пост. Восстановить его можно через {@link restore}. */
2597
3207
  remove(postId, options = {}) {
2598
- return this.http.operation("posts.remove", {
3208
+ return this.voidOperation(POSTS_REMOVE, {
2599
3209
  path: `/api/posts/${encodePathSegment(postId, "postId")}`,
2600
3210
  ...options
2601
3211
  });
2602
3212
  }
2603
3213
  /** Восстанавливает удалённый пост. */
2604
3214
  restore(postId, options = {}) {
2605
- return this.http.operation("posts.restore", {
3215
+ return this.http.execute(POSTS_RESTORE, {
2606
3216
  path: `/api/posts/${encodePathSegment(postId, "postId")}/restore`,
2607
3217
  ...options
2608
3218
  });
2609
3219
  }
2610
3220
  /** Ставит реакцию на пост. */
2611
3221
  like(postId, options = {}) {
2612
- return this.http.operation("posts.like", {
3222
+ return this.http.execute(POSTS_LIKE, {
2613
3223
  path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
2614
3224
  ...options
2615
3225
  });
2616
3226
  }
2617
3227
  /** Убирает реакцию с поста. */
2618
3228
  unlike(postId, options = {}) {
2619
- return this.http.operation("posts.unlike", {
3229
+ return this.http.execute(POSTS_UNLIKE, {
2620
3230
  path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
2621
3231
  ...options
2622
3232
  });
@@ -2628,7 +3238,7 @@ var PostsResource = class extends BaseResource {
2628
3238
  * для файлов здесь нет.
2629
3239
  */
2630
3240
  repost(postId, content = "", options = {}) {
2631
- return this.http.operation("posts.repost", {
3241
+ return this.http.execute(POSTS_REPOST, {
2632
3242
  path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
2633
3243
  body: { content },
2634
3244
  ...options
@@ -2636,21 +3246,21 @@ var PostsResource = class extends BaseResource {
2636
3246
  }
2637
3247
  /** Отменяет репост. */
2638
3248
  unrepost(postId, options = {}) {
2639
- return this.http.operation("posts.unrepost", {
3249
+ return this.voidOperation(POSTS_UNREPOST, {
2640
3250
  path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
2641
3251
  ...options
2642
3252
  });
2643
3253
  }
2644
3254
  /** Закрепляет пост в профиле. */
2645
3255
  pin(postId, options = {}) {
2646
- return this.http.operation("posts.pin", {
3256
+ return this.http.execute(POSTS_PIN, {
2647
3257
  path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
2648
3258
  ...options
2649
3259
  });
2650
3260
  }
2651
3261
  /** Открепляет пост. */
2652
3262
  unpin(postId, options = {}) {
2653
- return this.http.operation("posts.unpin", {
3263
+ return this.http.execute(POSTS_UNPIN, {
2654
3264
  path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
2655
3265
  ...options
2656
3266
  });
@@ -2661,20 +3271,19 @@ var PostsResource = class extends BaseResource {
2661
3271
  * @param optionIds выбранные варианты; несколько допустимы только при `multipleChoice`
2662
3272
  */
2663
3273
  vote(postId, optionIds, options = {}) {
2664
- return this.http.operation("posts.vote", {
3274
+ return this.http.execute(POSTS_VOTE, {
2665
3275
  path: `/api/posts/${encodePathSegment(postId, "postId")}/poll/vote`,
2666
3276
  body: { optionIds },
2667
3277
  ...options
2668
3278
  });
2669
3279
  }
2670
3280
  /** Запрашивает счётчики сразу для нескольких постов. */
2671
- async stats(ids, options = {}) {
2672
- const body = await this.http.operation("posts.stats", {
3281
+ stats(ids, options = {}) {
3282
+ return this.http.execute(POSTS_STATS, {
2673
3283
  path: "/api/posts/stats",
2674
3284
  body: { ids },
2675
3285
  ...options
2676
3286
  });
2677
- return pickArray(body, "posts");
2678
3287
  }
2679
3288
  /**
2680
3289
  * Загружает страницу стены пользователя.
@@ -2744,7 +3353,7 @@ var PostsResource = class extends BaseResource {
2744
3353
  async comment(postId, input, options = {}) {
2745
3354
  const data = resolveComment(typeof input === "string" ? { content: input } : input);
2746
3355
  const attachmentIds = await this.#collectAttachments(data, options);
2747
- return this.http.operation("posts.comment", {
3356
+ return this.http.execute(POSTS_COMMENT, {
2748
3357
  path: `/api/posts/${encodePathSegment(postId, "postId")}/comments`,
2749
3358
  body: {
2750
3359
  content: data.content ?? "",
@@ -2865,6 +3474,7 @@ function resolveReport(input) {
2865
3474
  }
2866
3475
  //#endregion
2867
3476
  //#region src/resources/reports.ts
3477
+ const REPORTS_CREATE = passthroughOperation("reports.create");
2868
3478
  /**
2869
3479
  * Жалобы на контент и пользователей.
2870
3480
  *
@@ -2885,7 +3495,7 @@ var ReportsResource = class extends BaseResource {
2885
3495
  */
2886
3496
  create(input, options = {}) {
2887
3497
  const data = resolveReport(input);
2888
- return this.http.operation("reports.create", {
3498
+ return this.http.execute(REPORTS_CREATE, {
2889
3499
  path: "/api/reports",
2890
3500
  body: data,
2891
3501
  ...options
@@ -2894,6 +3504,10 @@ var ReportsResource = class extends BaseResource {
2894
3504
  };
2895
3505
  //#endregion
2896
3506
  //#region src/resources/search.ts
3507
+ const SEARCH_ALL = defineBuiltInOperation("search.all", (body) => ({
3508
+ users: pickArray(body, "users"),
3509
+ hashtags: pickArray(body, "hashtags")
3510
+ }));
2897
3511
  /**
2898
3512
  * Глобальный поиск.
2899
3513
  *
@@ -2908,20 +3522,23 @@ var SearchResource = class extends BaseResource {
2908
3522
  * const { users, hashtags } = await itd.search.all('арт');
2909
3523
  * ```
2910
3524
  */
2911
- async all(query, options = {}) {
2912
- const body = await this.http.operation("search.all", {
3525
+ all(query, options = {}) {
3526
+ return this.http.execute(SEARCH_ALL, {
2913
3527
  path: "/api/search",
2914
3528
  query: { q: query },
2915
3529
  ...options
2916
3530
  });
2917
- return {
2918
- users: pickArray(body, "users"),
2919
- hashtags: pickArray(body, "hashtags")
2920
- };
2921
3531
  }
2922
3532
  };
2923
3533
  //#endregion
2924
3534
  //#region src/resources/subscription.ts
3535
+ const SUBSCRIPTION_METHODS = defineBuiltInOperation("subscription.methods", (body) => Array.isArray(body) ? body : []);
3536
+ const SUBSCRIPTION_STATUS = passthroughOperation("subscription.status");
3537
+ const SUBSCRIPTION_PAY = passthroughOperation("subscription.pay");
3538
+ const SUBSCRIPTION_SET_AUTO_RENEWAL = passthroughOperation("subscription.setAutoRenewal");
3539
+ const SUBSCRIPTION_BIND_CARD = passthroughOperation("subscription.bindCard");
3540
+ const SUBSCRIPTION_SET_DEFAULT_METHOD = passthroughOperation("subscription.setDefaultMethod");
3541
+ const SUBSCRIPTION_REMOVE_METHOD = voidOperation("subscription.removeMethod");
2925
3542
  /**
2926
3543
  * Подписка и способы оплаты.
2927
3544
  *
@@ -2930,7 +3547,7 @@ var SearchResource = class extends BaseResource {
2930
3547
  var SubscriptionResource = class extends BaseResource {
2931
3548
  /** Загружает состояние подписки и её цену. */
2932
3549
  status(options = {}) {
2933
- return this.http.operation("subscription.status", {
3550
+ return this.http.execute(SUBSCRIPTION_STATUS, {
2934
3551
  path: "/api/v1/subscription/",
2935
3552
  ...options
2936
3553
  });
@@ -2941,14 +3558,14 @@ var SubscriptionResource = class extends BaseResource {
2941
3558
  * Форма ответа в документации API не описана, поэтому тип результата не уточняется.
2942
3559
  */
2943
3560
  pay(options = {}) {
2944
- return this.http.operation("subscription.pay", {
3561
+ return this.http.execute(SUBSCRIPTION_PAY, {
2945
3562
  path: "/api/v1/subscription/pay",
2946
3563
  ...options
2947
3564
  });
2948
3565
  }
2949
3566
  /** Включает или отключает автопродление. */
2950
3567
  setAutoRenewal(enabled, options = {}) {
2951
- return this.http.operation("subscription.setAutoRenewal", {
3568
+ return this.http.execute(SUBSCRIPTION_SET_AUTO_RENEWAL, {
2952
3569
  path: "/api/v1/subscription/auto-renewal",
2953
3570
  body: { enabled },
2954
3571
  ...options
@@ -2956,29 +3573,28 @@ var SubscriptionResource = class extends BaseResource {
2956
3573
  }
2957
3574
  /** Запускает привязку карты. */
2958
3575
  bindCard(options = {}) {
2959
- return this.http.operation("subscription.bindCard", {
3576
+ return this.http.execute(SUBSCRIPTION_BIND_CARD, {
2960
3577
  path: "/api/v1/subscription/bind-card",
2961
3578
  ...options
2962
3579
  });
2963
3580
  }
2964
3581
  /** Загружает список способов оплаты. Пустой массив, если карт нет. */
2965
- async methods(options = {}) {
2966
- const body = await this.http.operation("subscription.methods", {
3582
+ methods(options = {}) {
3583
+ return this.http.execute(SUBSCRIPTION_METHODS, {
2967
3584
  path: "/api/v1/subscription/methods",
2968
3585
  ...options
2969
3586
  });
2970
- return Array.isArray(body) ? body : [];
2971
3587
  }
2972
3588
  /** Делает способ оплаты основным. */
2973
3589
  setDefaultMethod(methodId, options = {}) {
2974
- return this.http.operation("subscription.setDefaultMethod", {
3590
+ return this.http.execute(SUBSCRIPTION_SET_DEFAULT_METHOD, {
2975
3591
  path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}/default`,
2976
3592
  ...options
2977
3593
  });
2978
3594
  }
2979
3595
  /** Удаляет способ оплаты. */
2980
3596
  removeMethod(methodId, options = {}) {
2981
- return this.http.operation("subscription.removeMethod", {
3597
+ return this.voidOperation(SUBSCRIPTION_REMOVE_METHOD, {
2982
3598
  path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}`,
2983
3599
  ...options
2984
3600
  });
@@ -3137,7 +3753,7 @@ var TelemetryBatchImpl = class {
3137
3753
  close() {
3138
3754
  return this.#close(false);
3139
3755
  }
3140
- /** Внутренняя финализация после перехода клиента в terminal state. */
3756
+ /** Внутренняя отправка данных после вызова `dispose()`. */
3141
3757
  closeForDispose() {
3142
3758
  this.prepareForDispose();
3143
3759
  return this.#close(true);
@@ -3233,7 +3849,7 @@ var TelemetryResource = class extends BaseResource {
3233
3849
  },
3234
3850
  ...requestOptions
3235
3851
  };
3236
- return disposeCleanup ? this.http.cleanupOperation("telemetry.dwell", options) : this.http.operation("telemetry.dwell", options);
3852
+ return disposeCleanup ? this.http.cleanupOperation(TELEMETRY_DWELL, options) : this.http.execute(TELEMETRY_DWELL, options);
3237
3853
  }
3238
3854
  /** Отправляет события взаимодействия с контентом (`POST /api/v1/x`). */
3239
3855
  interaction(entries, telemetryOptions = {}, requestOptions = {}) {
@@ -3257,7 +3873,7 @@ var TelemetryResource = class extends BaseResource {
3257
3873
  },
3258
3874
  ...requestOptions
3259
3875
  };
3260
- return disposeCleanup ? this.http.cleanupOperation("telemetry.interaction", options) : this.http.operation("telemetry.interaction", options);
3876
+ return disposeCleanup ? this.http.cleanupOperation(TELEMETRY_INTERACTION, options) : this.http.execute(TELEMETRY_INTERACTION, options);
3261
3877
  }
3262
3878
  /** Начинает измерять время просмотра и отправляет результат после `finish()`. */
3263
3879
  startView(input, options = {}, requestOptions = {}) {
@@ -3300,12 +3916,43 @@ var TelemetryResource = class extends BaseResource {
3300
3916
  function prepareTelemetryForDispose(resource) {
3301
3917
  if (resource) TELEMETRY_DISPOSE_PREPARERS.get(resource)?.();
3302
3918
  }
3303
- /** Финализирует созданные накопители после перехода клиента в terminal state. @internal */
3919
+ /** Отправляет и закрывает созданные накопители после вызова `dispose()`. @internal */
3304
3920
  function closeTelemetryForDispose(resource) {
3305
3921
  return resource ? TELEMETRY_DISPOSE_CLOSERS.get(resource)?.() ?? Promise.resolve() : Promise.resolve();
3306
3922
  }
3923
+ const TELEMETRY_DWELL = defineBuiltInOperation("telemetry.dwell");
3924
+ const TELEMETRY_INTERACTION = defineBuiltInOperation("telemetry.interaction");
3307
3925
  //#endregion
3308
3926
  //#region src/resources/users.ts
3927
+ const userListOperation = (id) => pageOperation(id, (body) => readPagedPage(body, "users", "followers", "following", "blocked"));
3928
+ const USER_LIST_OPERATIONS = {
3929
+ "users.followers": userListOperation("users.followers"),
3930
+ "users.following": userListOperation("users.following"),
3931
+ "users.blocked": userListOperation("users.blocked")
3932
+ };
3933
+ const USERS_CHECK_USERNAME = defineBuiltInOperation("users.checkUsername", (body) => pickBoolean(body, "available"));
3934
+ const USERS_SEARCH = defineBuiltInOperation("users.search", (body) => pickArray(body, "users"));
3935
+ const USERS_WHO_TO_FOLLOW = defineBuiltInOperation("users.whoToFollow", (body) => pickArray(body, "users"));
3936
+ const USERS_TOP_CLANS = defineBuiltInOperation("users.topClans", (body) => pickArray(body, "clans"));
3937
+ const USERS_PINS = defineBuiltInOperation("users.pins", (body) => ({
3938
+ pins: pickArray(body, "pins"),
3939
+ activePin: pickString(body, "activePin") ?? null
3940
+ }));
3941
+ const USERS_ME = passthroughOperation("users.me");
3942
+ const USERS_UPDATE_ME = passthroughOperation("users.updateMe");
3943
+ const USERS_CREATE_PROFILE = passthroughOperation("users.createProfile");
3944
+ const USERS_GET = passthroughOperation("users.get");
3945
+ const USERS_FOLLOW = passthroughOperation("users.follow");
3946
+ const USERS_FOLLOW_STATUS = passthroughOperation("users.followStatus");
3947
+ const USERS_GET_PRIVACY = passthroughOperation("users.getPrivacy");
3948
+ const USERS_UPDATE_PRIVACY = passthroughOperation("users.updatePrivacy");
3949
+ const USERS_DEACTIVATE = voidOperation("users.deactivate");
3950
+ const USERS_RESTORE = voidOperation("users.restore");
3951
+ const USERS_UNFOLLOW = voidOperation("users.unfollow");
3952
+ const USERS_BLOCK = voidOperation("users.block");
3953
+ const USERS_UNBLOCK = voidOperation("users.unblock");
3954
+ const USERS_SET_PIN = voidOperation("users.setPin");
3955
+ const USERS_REMOVE_PIN = voidOperation("users.removePin");
3309
3956
  /**
3310
3957
  * Пользователи: профили, подписки, блокировки, приватность.
3311
3958
  *
@@ -3326,23 +3973,22 @@ var UsersResource = class extends BaseResource {
3326
3973
  * сервер его сейчас не читает (см. {@link followers}): когда починят — заработает само.
3327
3974
  */
3328
3975
  #userList = this.paginated({
3329
- operationId: (p) => p.operationId,
3976
+ operation: (p) => p.operation,
3330
3977
  path: (p) => p.path,
3331
3978
  query: (p) => ({ limit: p.limit }),
3332
3979
  start: (p) => p.page !== void 0 ? { page: p.page } : {},
3333
- read: (body) => readPagedPage(body, "users", "followers", "following", "blocked"),
3334
3980
  mode: PaginationMode.Page
3335
3981
  });
3336
3982
  /** Загружает свой профиль — с подпиской и признаком подтверждённого телефона. */
3337
3983
  me(options = {}) {
3338
- return this.http.operation("users.me", {
3984
+ return this.http.execute(USERS_ME, {
3339
3985
  path: "/api/users/me",
3340
3986
  ...options
3341
3987
  });
3342
3988
  }
3343
3989
  /** Обновляет свой профиль. Передавайте только изменяемые поля. */
3344
3990
  updateMe(input, options = {}) {
3345
- return this.http.operation("users.updateMe", {
3991
+ return this.http.execute(USERS_UPDATE_ME, {
3346
3992
  path: "/api/users/me",
3347
3993
  body: input,
3348
3994
  ...options
@@ -3369,21 +4015,21 @@ var UsersResource = class extends BaseResource {
3369
4015
  }
3370
4016
  /** Деактивирует аккаунт. Вернуть его можно через {@link restore}. */
3371
4017
  deactivate(options = {}) {
3372
- return this.http.operation("users.deactivate", {
4018
+ return this.voidOperation(USERS_DEACTIVATE, {
3373
4019
  path: "/api/users/me",
3374
4020
  ...options
3375
4021
  });
3376
4022
  }
3377
4023
  /** Восстанавливает деактивированный аккаунт. */
3378
4024
  restore(options = {}) {
3379
- return this.http.operation("users.restore", {
4025
+ return this.voidOperation(USERS_RESTORE, {
3380
4026
  path: "/api/users/me/restore",
3381
4027
  ...options
3382
4028
  });
3383
4029
  }
3384
4030
  /** Создаёт профиль после регистрации. */
3385
4031
  createProfile(input, options = {}) {
3386
- return this.http.operation("users.createProfile", {
4032
+ return this.http.execute(USERS_CREATE_PROFILE, {
3387
4033
  path: "/api/users/profile",
3388
4034
  body: input,
3389
4035
  ...options
@@ -3401,23 +4047,22 @@ var UsersResource = class extends BaseResource {
3401
4047
  * ```
3402
4048
  */
3403
4049
  get(user, options = {}) {
3404
- return this.http.operation("users.get", {
4050
+ return this.http.execute(USERS_GET, {
3405
4051
  path: `/api/users/${encodePathSegment(user, "user")}`,
3406
4052
  ...options
3407
4053
  });
3408
4054
  }
3409
4055
  /** Проверяет, свободно ли имя пользователя. */
3410
- async checkUsername(username, options = {}) {
3411
- const body = await this.http.operation("users.checkUsername", {
4056
+ checkUsername(username, options = {}) {
4057
+ return this.http.execute(USERS_CHECK_USERNAME, {
3412
4058
  path: "/api/users/check-username",
3413
4059
  query: { username },
3414
4060
  ...options
3415
4061
  });
3416
- return pickBoolean(body, "available");
3417
4062
  }
3418
4063
  /** Ищет пользователей по строке запроса. */
3419
- async search(query, params = {}, options = {}) {
3420
- const body = await this.http.operation("users.search", {
4064
+ search(query, params = {}, options = {}) {
4065
+ return this.http.execute(USERS_SEARCH, {
3421
4066
  path: "/api/users/search",
3422
4067
  query: {
3423
4068
  q: query,
@@ -3425,23 +4070,20 @@ var UsersResource = class extends BaseResource {
3425
4070
  },
3426
4071
  ...options
3427
4072
  });
3428
- return pickArray(body, "users");
3429
4073
  }
3430
4074
  /** Загружает рекомендации, на кого подписаться. */
3431
- async whoToFollow(options = {}) {
3432
- const body = await this.http.operation("users.whoToFollow", {
4075
+ whoToFollow(options = {}) {
4076
+ return this.http.execute(USERS_WHO_TO_FOLLOW, {
3433
4077
  path: "/api/users/suggestions/who-to-follow",
3434
4078
  ...options
3435
4079
  });
3436
- return pickArray(body, "users");
3437
4080
  }
3438
4081
  /** Загружает рейтинг кланов. */
3439
- async topClans(options = {}) {
3440
- const body = await this.http.operation("users.topClans", {
4082
+ topClans(options = {}) {
4083
+ return this.http.execute(USERS_TOP_CLANS, {
3441
4084
  path: "/api/users/stats/top-clans",
3442
4085
  ...options
3443
4086
  });
3444
- return pickArray(body, "clans");
3445
4087
  }
3446
4088
  /**
3447
4089
  * Подписывается на пользователя.
@@ -3449,7 +4091,7 @@ var UsersResource = class extends BaseResource {
3449
4091
  * У закрытого профиля вместо подписки отправляется заявка — это видно по полю `status`.
3450
4092
  */
3451
4093
  follow(user, options = {}) {
3452
- return this.http.operation("users.follow", {
4094
+ return this.http.execute(USERS_FOLLOW, {
3453
4095
  path: `/api/users/${encodePathSegment(user, "user")}/follow`,
3454
4096
  body: {},
3455
4097
  ...options
@@ -3457,7 +4099,7 @@ var UsersResource = class extends BaseResource {
3457
4099
  }
3458
4100
  /** Отписывается от пользователя. */
3459
4101
  unfollow(user, options = {}) {
3460
- return this.http.operation("users.unfollow", {
4102
+ return this.voidOperation(USERS_UNFOLLOW, {
3461
4103
  path: `/api/users/${encodePathSegment(user, "user")}/follow`,
3462
4104
  ...options
3463
4105
  });
@@ -3505,7 +4147,7 @@ var UsersResource = class extends BaseResource {
3505
4147
  * ```
3506
4148
  */
3507
4149
  followStatus(userIds, options = {}) {
3508
- return this.http.operation("users.followStatus", {
4150
+ return this.http.execute(USERS_FOLLOW_STATUS, {
3509
4151
  path: "/api/users/follow-status",
3510
4152
  body: { userIds },
3511
4153
  ...options
@@ -3513,7 +4155,7 @@ var UsersResource = class extends BaseResource {
3513
4155
  }
3514
4156
  /** Блокирует пользователя. */
3515
4157
  block(user, options = {}) {
3516
- return this.http.operation("users.block", {
4158
+ return this.voidOperation(USERS_BLOCK, {
3517
4159
  path: `/api/users/${encodePathSegment(user, "user")}/block`,
3518
4160
  body: {},
3519
4161
  ...options
@@ -3521,7 +4163,7 @@ var UsersResource = class extends BaseResource {
3521
4163
  }
3522
4164
  /** Снимает блокировку. */
3523
4165
  unblock(user, options = {}) {
3524
- return this.http.operation("users.unblock", {
4166
+ return this.voidOperation(USERS_UNBLOCK, {
3525
4167
  path: `/api/users/${encodePathSegment(user, "user")}/block`,
3526
4168
  ...options
3527
4169
  });
@@ -3536,14 +4178,14 @@ var UsersResource = class extends BaseResource {
3536
4178
  }
3537
4179
  /** Загружает настройки приватности. */
3538
4180
  getPrivacy(options = {}) {
3539
- return this.http.operation("users.getPrivacy", {
4181
+ return this.http.execute(USERS_GET_PRIVACY, {
3540
4182
  path: "/api/users/me/privacy",
3541
4183
  ...options
3542
4184
  });
3543
4185
  }
3544
4186
  /** Обновляет настройки приватности. Передавайте только изменяемые поля. */
3545
4187
  updatePrivacy(input, options = {}) {
3546
- return this.http.operation("users.updatePrivacy", {
4188
+ return this.http.execute(USERS_UPDATE_PRIVACY, {
3547
4189
  path: "/api/users/me/privacy",
3548
4190
  body: input,
3549
4191
  ...options
@@ -3554,19 +4196,15 @@ var UsersResource = class extends BaseResource {
3554
4196
  *
3555
4197
  * `activePin` — строка-идентификатор, а не объект.
3556
4198
  */
3557
- async pins(options = {}) {
3558
- const body = await this.http.operation("users.pins", {
4199
+ pins(options = {}) {
4200
+ return this.http.execute(USERS_PINS, {
3559
4201
  path: "/api/users/me/pins",
3560
4202
  ...options
3561
4203
  });
3562
- return {
3563
- pins: pickArray(body, "pins"),
3564
- activePin: pickString(body, "activePin") ?? null
3565
- };
3566
4204
  }
3567
4205
  /** Выбирает активный значок профиля. */
3568
4206
  setPin(slug, options = {}) {
3569
- return this.http.operation("users.setPin", {
4207
+ return this.voidOperation(USERS_SET_PIN, {
3570
4208
  path: "/api/users/me/pin",
3571
4209
  body: { slug },
3572
4210
  ...options
@@ -3574,7 +4212,7 @@ var UsersResource = class extends BaseResource {
3574
4212
  }
3575
4213
  /** Снимает активный значок. */
3576
4214
  removePin(options = {}) {
3577
- return this.http.operation("users.removePin", {
4215
+ return this.voidOperation(USERS_REMOVE_PIN, {
3578
4216
  path: "/api/users/me/pin",
3579
4217
  ...options
3580
4218
  });
@@ -3582,20 +4220,22 @@ var UsersResource = class extends BaseResource {
3582
4220
  #userPage(operationId, path, params, options) {
3583
4221
  return this.#userList.list({
3584
4222
  ...params,
3585
- operationId,
4223
+ operation: USER_LIST_OPERATIONS[operationId],
3586
4224
  path
3587
4225
  }, options);
3588
4226
  }
3589
4227
  #userPaginator(operationId, path, params, options) {
3590
4228
  return this.#userList.iterate({
3591
4229
  ...params,
3592
- operationId,
4230
+ operation: USER_LIST_OPERATIONS[operationId],
3593
4231
  path
3594
4232
  }, options);
3595
4233
  }
3596
4234
  };
3597
4235
  //#endregion
3598
4236
  //#region src/resources/verification.ts
4237
+ const VERIFICATION_STATUS = passthroughOperation("verification.status");
4238
+ const VERIFICATION_SUBMIT = passthroughOperation("verification.submit");
3599
4239
  /**
3600
4240
  * Верификация профиля.
3601
4241
  *
@@ -3604,14 +4244,14 @@ var UsersResource = class extends BaseResource {
3604
4244
  var VerificationResource = class extends BaseResource {
3605
4245
  /** Загружает статус заявки. Значение `none` означает, что заявка не подавалась. */
3606
4246
  status(options = {}) {
3607
- return this.http.operation("verification.status", {
4247
+ return this.http.execute(VERIFICATION_STATUS, {
3608
4248
  path: "/api/verification/status",
3609
4249
  ...options
3610
4250
  });
3611
4251
  }
3612
4252
  /** Подаёт заявку на верификацию с видео. */
3613
4253
  submit(videoUrl, options = {}) {
3614
- return this.http.operation("verification.submit", {
4254
+ return this.http.execute(VERIFICATION_SUBMIT, {
3615
4255
  path: "/api/verification/submit",
3616
4256
  body: { videoUrl },
3617
4257
  ...options
@@ -3656,11 +4296,11 @@ function createResources(deps) {
3656
4296
  return comments;
3657
4297
  },
3658
4298
  get files() {
3659
- files ??= new FilesResource(deps.http, { fetch: deps.fetch });
4299
+ files ??= new FilesResource(deps.http, { files: deps.files });
3660
4300
  return files;
3661
4301
  },
3662
4302
  get notifications() {
3663
- notifications ??= new NotificationsResource(deps.http);
4303
+ notifications ??= deps.createNotifications(deps.http);
3664
4304
  return notifications;
3665
4305
  },
3666
4306
  get hashtags() {
@@ -3684,7 +4324,7 @@ function createResources(deps) {
3684
4324
  return subscription;
3685
4325
  },
3686
4326
  get platform() {
3687
- platform ??= new PlatformResource(deps.http);
4327
+ platform ??= new PlatformResource(deps.http, deps.status);
3688
4328
  return platform;
3689
4329
  },
3690
4330
  get telemetry() {
@@ -3724,7 +4364,7 @@ function fromStream(factory, options = {}) {
3724
4364
  throw new ItdFileError("не удалось открыть поток вложения", {
3725
4365
  reason: ItdFileErrorReason.Read,
3726
4366
  retryable: true,
3727
- cause: error
4367
+ cause: redactErrorCause(error)
3728
4368
  });
3729
4369
  }
3730
4370
  const content = isReadableByteStream(opened) ? { stream: opened } : opened;
@@ -3987,6 +4627,6 @@ function renderSpans(content, spans = [], options = {}) {
3987
4627
  return result;
3988
4628
  }
3989
4629
  //#endregion
3990
- export { BaseResource as C, mapPage as E, isBuilder as S, Paginator as T, ALLOWED_MIME_TYPES as _, fromStream as a, VIDEO_MIME_TYPES as b, report as c, autoSpans as d, markup as f, utcStampToIso as g, toDate as h, isMyProfile as i, post as l, parseMarkdown as m, renderSpans as n, fromUrl as o, parseHtml as p, statusDays as r, createResources as s, SpanRenderFormat as t, poll as u, AUDIO_MIME_TYPES as v, PaginationMode as w, comment as x, IMAGE_MIME_TYPES as y };
4630
+ export { ExtensibleOperationCatalog as A, utcStampToIso as C, Paginator as D, PaginationMode as E, mapPage as O, toDate as S, BaseResource as T, IMAGE_MIME_TYPES as _, fromStream as a, isBuilder as b, report as c, autoSpans as d, markup as f, AUDIO_MIME_TYPES as g, ALLOWED_MIME_TYPES as h, isMyProfile as i, createFeatureHost as k, post as l, parseMarkdown as m, renderSpans as n, fromUrl as o, parseHtml as p, statusDays as r, createResources as s, SpanRenderFormat as t, poll as u, VIDEO_MIME_TYPES as v, NotificationsResource as w, createStatusFeature as x, comment as y };
3991
4631
 
3992
- //# sourceMappingURL=render-vtLixIiU.js.map
4632
+ //# sourceMappingURL=render-CiL1VS0b.js.map