itd-api 0.3.0 → 0.4.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.
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_multi_storage = require("./multi-storage-D1keK2Op.cjs");
2
+ const require_multi_storage = require("./multi-storage-C8R5Crwo.cjs");
3
3
  const require_storage = require("./storage-ycBqLBRB.cjs");
4
4
  const require_runtime = require("./runtime-CFEsf-jD.cjs");
5
5
  //#region src/core/emitter.ts
@@ -727,7 +727,7 @@ function normalizeBaseUrl(baseUrl) {
727
727
  //#endregion
728
728
  //#region src/core/version.ts
729
729
  /** Версия библиотеки. Попадает в `User-Agent`. */
730
- const LIBRARY_VERSION = "0.3.0";
730
+ const LIBRARY_VERSION = "0.4.0";
731
731
  //#endregion
732
732
  //#region src/core/config.ts
733
733
  /** Базовый URL API итд.com. Домен записан в punycode: `итд.com`. */
@@ -1052,15 +1052,271 @@ function withLayerHeaders(request, headers) {
1052
1052
  };
1053
1053
  }
1054
1054
  //#endregion
1055
- //#region src/core/plugins.ts
1056
- /** Пустой набор отдаётся, пока плагинов нет, чтобы не заводить объект на каждый запрос. */
1057
- const NO_KEYS = /* @__PURE__ */ new Set();
1055
+ //#region src/core/plugins/hooks.ts
1056
+ const REQUEST_HOOK_DISPATCHERS = /* @__PURE__ */ new WeakMap();
1057
+ const PLUGIN_HOOK_SCOPE = Symbol("itd-api.plugin-hooks");
1058
+ /** Создаёт динамический набор hooks, связанный с диспетчером registry. @internal */
1059
+ function createRequestHooks(dispatcher) {
1060
+ const hooks = {};
1061
+ REQUEST_HOOK_DISPATCHERS.set(hooks, dispatcher);
1062
+ return hooks;
1063
+ }
1064
+ /** Привязывает к запросу неизменяемый снимок plugin hooks. @internal */
1065
+ function withRequestHookScope(request, hooks) {
1066
+ const scoped = request;
1067
+ return scoped[PLUGIN_HOOK_SCOPE] === hooks ? scoped : {
1068
+ ...request,
1069
+ [PLUGIN_HOOK_SCOPE]: hooks
1070
+ };
1071
+ }
1072
+ /** Читает снимок plugin hooks, привязанный к логическому запросу. @internal */
1073
+ function requestHookScope(request) {
1074
+ return request[PLUGIN_HOOK_SCOPE] ?? [];
1075
+ }
1076
+ /**
1077
+ * Вызывает публичный хук, сохраняя привязанный к логическому запросу снимок плагинов.
1078
+ *
1079
+ * Обычные наборы хуков по-прежнему получают только публичный контекст. Дополнительный
1080
+ * аргумент используется исключительно внутренним составным набором PluginRegistry.
1081
+ *
1082
+ * @internal
1083
+ */
1084
+ async function dispatchRequestHook(hooks, field, context, request) {
1085
+ const dispatcher = REQUEST_HOOK_DISPATCHERS.get(hooks);
1086
+ if (dispatcher) {
1087
+ await dispatcher(field, context, request);
1088
+ return;
1089
+ }
1090
+ const hook = hooks[field];
1091
+ await hook?.(context);
1092
+ }
1093
+ //#endregion
1094
+ //#region src/core/retry.ts
1095
+ /**
1096
+ * Методы, повтор которых безопасен по определению.
1097
+ *
1098
+ * `DELETE` формально тоже идемпотентен, но его повтор после успеха вернёт `404`
1099
+ * и собьёт с толку — в список он не входит.
1100
+ */
1101
+ const IDEMPOTENT_METHODS = /* @__PURE__ */ new Set([
1102
+ "GET",
1103
+ "HEAD",
1104
+ "OPTIONS"
1105
+ ]);
1106
+ /**
1107
+ * Стоит ли повторять запрос после этой ошибки.
1108
+ *
1109
+ * Отдельно разобран `429`: он повторяется даже для запросов на запись, потому что
1110
+ * гарантирует, что запрос **не был обработан**. Обрыв сети и `5xx` такой гарантии не дают —
1111
+ * сервер мог успеть создать пост, — поэтому запись по умолчанию не повторяется.
1112
+ */
1113
+ function isRetryable(error, method, retryWrites, retryNetworkWrite) {
1114
+ if (error instanceof require_storage.ItdAbortError) return false;
1115
+ const safeToRepeat = retryWrites || IDEMPOTENT_METHODS.has(method);
1116
+ if (error instanceof require_storage.ItdApiError) {
1117
+ if (error.status === 429) return true;
1118
+ if (error.status >= 500) return safeToRepeat;
1119
+ return false;
1120
+ }
1121
+ if (error instanceof require_storage.ItdNetworkError || error instanceof require_storage.ItdTimeoutError) return safeToRepeat || retryNetworkWrite;
1122
+ if (error instanceof require_storage.ItdFileError) return error.retryable && (safeToRepeat || retryNetworkWrite);
1123
+ return false;
1124
+ }
1125
+ /** Экспоненциальная пауза со случайным разбросом. */
1126
+ function backoffDelay(attempt, options, random) {
1127
+ const exponential = options.baseDelay * 2 ** (attempt - 1);
1128
+ const capped = Math.min(exponential, options.maxDelay);
1129
+ const spread = capped * options.jitter * (random() * 2 - 1);
1130
+ return Math.max(0, Math.round(capped + spread));
1131
+ }
1132
+ /**
1133
+ * Собирает планировщик повторов для транспорта.
1134
+ *
1135
+ * Поведение при `Retry-After`: пауза, названная сервером, соблюдается точно — она
1136
+ * авторитетнее нашего расчёта. Но если сервер просит ждать дольше, чем `maxDelay`,
1137
+ * повтор **не выполняется вовсе**: молча спать десять минут внутри вызова библиотека
1138
+ * не должна, лучше отдать {@link ItdRateLimitError} и дать решить вызывающему коду.
1139
+ *
1140
+ * @param options настройки повторов после подстановки значений по умолчанию
1141
+ * @param random источник случайности; подменяется в тестах ради предсказуемости
1142
+ *
1143
+ * @example
1144
+ * ```ts
1145
+ * const scheduler = createRetryScheduler(config.retry);
1146
+ * const delay = scheduler(error, 1, 'GET'); // 500 мс ± 30%
1147
+ * ```
1148
+ */
1149
+ function createRetryScheduler(options, random = Math.random) {
1150
+ return (error, attempt, method, retryNetworkWrite = false) => {
1151
+ if (attempt >= options.attempts) return void 0;
1152
+ if (options.shouldRetry) return options.shouldRetry(error, attempt) ? backoffDelay(attempt, options, random) : void 0;
1153
+ if (!isRetryable(error, method, options.retryWrites, retryNetworkWrite)) return void 0;
1154
+ if (error instanceof require_storage.ItdApiError && error.retryAfter !== void 0) return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
1155
+ return backoffDelay(attempt, options, random);
1156
+ };
1157
+ }
1158
+ //#endregion
1159
+ //#region src/core/middleware.ts
1160
+ /** Ожидание повтора, которое уважает отмену запроса. */
1161
+ function sleep(clock, ms, signal) {
1162
+ if (!signal) return new Promise((resolve) => clock.schedule(resolve, ms));
1163
+ if (signal.aborted) return Promise.reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1164
+ return new Promise((resolve, reject) => {
1165
+ const cancel = clock.schedule(() => {
1166
+ signal.removeEventListener("abort", onAbort);
1167
+ resolve();
1168
+ }, ms);
1169
+ const onAbort = () => {
1170
+ cancel();
1171
+ reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1172
+ };
1173
+ signal.addEventListener("abort", onAbort, { once: true });
1174
+ });
1175
+ }
1176
+ /**
1177
+ * Слой очереди: ограничение конкурентности и частоты.
1178
+ *
1179
+ * `skipQueue` пропускает запрос мимо очереди — так поступают служебные запросы, которые
1180
+ * порождены изнутри другого запроса и не могут ждать освободившегося слота.
1181
+ *
1182
+ * Очередь выбирается по запросу: у каждого сервиса платформы свой хост и свой лимит.
1183
+ */
1184
+ function createQueueMiddleware(schedule) {
1185
+ return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
1186
+ }
1187
+ /**
1188
+ * Слой плагинов.
1189
+ *
1190
+ * Стоит снаружи повторов и внутри очереди: плагин должен увидеть запрос и ответ по одному
1191
+ * разу, независимо от числа попыток, — иначе, например, текст поста зашифруется дважды.
1192
+ */
1193
+ function createPluginsMiddleware(plugins) {
1194
+ return (request, next) => plugins.run(request, next);
1195
+ }
1196
+ /**
1197
+ * Слой сервисов.
1198
+ *
1199
+ * Запросу с полем `service` подставляет хост сервиса, его заголовки и `skipAuth`, если
1200
+ * сервис объявлен публичным. Заданный у запроса `baseUrl` не трогает.
1201
+ *
1202
+ * Стоит снаружи повторов и авторизации, чтобы выставленный здесь `skipAuth` был ей виден.
1203
+ */
1204
+ function createServicesMiddleware(registry) {
1205
+ return async (request, next) => {
1206
+ const service = request.service === void 0 ? void 0 : registry.require(request.service);
1207
+ let prepared = request;
1208
+ if (request.baseUrl !== void 0) {
1209
+ const baseUrl = normalizeBaseUrl(request.baseUrl);
1210
+ if (baseUrl !== request.baseUrl) prepared = {
1211
+ ...prepared,
1212
+ baseUrl
1213
+ };
1214
+ if (!(service?.baseUrl === baseUrl ? service.auth !== false : registry.isPrimarySite(baseUrl)) && prepared.skipAuth === void 0) prepared = {
1215
+ ...prepared,
1216
+ skipAuth: true
1217
+ };
1218
+ }
1219
+ if (!service) return next(prepared);
1220
+ if (prepared.baseUrl === void 0) prepared = {
1221
+ ...prepared,
1222
+ baseUrl: service.baseUrl
1223
+ };
1224
+ if (service.headers) prepared = withLayerHeaders(prepared, service.headers);
1225
+ if (service.auth === false && prepared.skipAuth === void 0) prepared = {
1226
+ ...prepared,
1227
+ skipAuth: true
1228
+ };
1229
+ return next(prepared);
1230
+ };
1231
+ }
1232
+ async function applyAuth(request, deps) {
1233
+ if (request.skipAuth) return request;
1234
+ const headers = await deps.getAuthHeaders();
1235
+ return Object.keys(headers).length > 0 ? withLayerHeaders(request, headers) : request;
1236
+ }
1058
1237
  /**
1059
- * Имена, которые плагин заявить не может.
1238
+ * Слой авторизации.
1060
1239
  *
1061
- * Заявленные опции ресурсы переносят в описание запроса поверх собранных полей, поэтому
1062
- * имя из {@link RawRequestOptions} подменило бы путь, тело или заголовки любого вызова.
1240
+ * Подставляет заголовок `Authorization` и обрабатывает `401`: обновляет токен и повторяет
1241
+ * запрос ровно один раз. Стоит внутри повторов, поэтому обычным попыткам он не виден —
1242
+ * они уже работают со свежим токеном.
1063
1243
  */
1244
+ function createAuthMiddleware(deps) {
1245
+ return async (request, next) => {
1246
+ const authorized = await applyAuth(request, deps);
1247
+ try {
1248
+ return await next(authorized);
1249
+ } catch (error) {
1250
+ if (request.skipAuthRefresh || !deps.autoRefresh || !require_storage.isItdApiError(error) || error.status !== 401) throw error;
1251
+ if (!await deps.onUnauthorized()) throw error;
1252
+ return next(await applyAuth({
1253
+ ...request,
1254
+ skipAuthRefresh: true
1255
+ }, deps));
1256
+ }
1257
+ };
1258
+ }
1259
+ /**
1260
+ * Выбирает планировщик отката для конкретного запроса.
1261
+ *
1262
+ * `retry` у запроса переопределяет глобальную настройку: `false` выключает повторы,
1263
+ * объект задаёт свои. Обработка `429` от этого не зависит — она общая.
1264
+ */
1265
+ function resolveBackoff(retry, global) {
1266
+ if (retry === void 0) return global;
1267
+ if (retry === false) return void 0;
1268
+ const resolved = resolveRetry(retry);
1269
+ return resolved ? createRetryScheduler(resolved) : void 0;
1270
+ }
1271
+ /**
1272
+ * Слой повторов.
1273
+ *
1274
+ * Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
1275
+ * всей очереди; сетевые сбои и `5xx` — экспоненциальным откатом. Настройка `retry`
1276
+ * у отдельного запроса имеет приоритет над глобальной.
1277
+ */
1278
+ function createRetryMiddleware(deps) {
1279
+ const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
1280
+ const nextDelay = (error, attempt, request, method, backoff) => {
1281
+ if (require_storage.isItdRateLimitError(error)) {
1282
+ const wait = error.retryAfter ?? deps.rateLimitDelays[attempt - 1];
1283
+ if (wait === void 0) return void 0;
1284
+ deps.pauseQueue?.(wait, request);
1285
+ deps.logger?.debug(`лимит частоты, попытка ${attempt + 1} через ${wait} мс`);
1286
+ return wait;
1287
+ }
1288
+ return backoff?.(error, attempt, method, request.retryNetworkWrite ?? false);
1289
+ };
1290
+ return async (request, next) => {
1291
+ const method = request.method.toUpperCase();
1292
+ const backoff = resolveBackoff(request.retry, globalScheduler);
1293
+ for (let attempt = 1;; attempt++) try {
1294
+ return await next({
1295
+ ...request,
1296
+ attempt
1297
+ });
1298
+ } catch (error) {
1299
+ const delay = nextDelay(error, attempt, request, method, backoff);
1300
+ if (delay === void 0) throw error;
1301
+ await dispatchRequestHook(deps.hooks, "onRetry", {
1302
+ method,
1303
+ path: request.path,
1304
+ url: deps.buildUrl(request),
1305
+ headers: new Headers({
1306
+ ...request.layerHeaders,
1307
+ ...request.headers
1308
+ }),
1309
+ attempt,
1310
+ error,
1311
+ delay
1312
+ }, request);
1313
+ deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${attempt + 1} через ${delay} мс`);
1314
+ await sleep(deps.clock ?? systemClock, delay, request.signal);
1315
+ }
1316
+ };
1317
+ }
1318
+ //#endregion
1319
+ //#region src/core/plugins/order.ts
1064
1320
  const RESERVED_OPTION_KEYS = /* @__PURE__ */ new Set([
1065
1321
  "signal",
1066
1322
  "timeout",
@@ -1101,7 +1357,8 @@ function validateNameList(plugin, field) {
1101
1357
  seen.add(value);
1102
1358
  }
1103
1359
  }
1104
- function validateHooks(plugin, hooks) {
1360
+ /** Проверяет набор hooks, переданный плагином. @internal */
1361
+ function validatePluginHooks(plugin, hooks) {
1105
1362
  if (typeof hooks !== "object" || hooks === null) throw new require_storage.ItdConfigError(`плагин «${plugin}» передал в useHooks() не объект`);
1106
1363
  for (const field of HOOK_FIELDS) if (hooks[field] !== void 0 && typeof hooks[field] !== "function") throw new require_storage.ItdConfigError(`плагин «${plugin}»: useHooks().${field} должен быть функцией`);
1107
1364
  }
@@ -1188,30 +1445,14 @@ function orderPluginDefinitions(plugins) {
1188
1445
  if (ordered.length !== entries.length) throw new require_storage.ItdConfigError(`циклический порядок плагинов: ${entries.filter(({ plugin }) => (indegree.get(plugin.name) ?? 0) > 0).map(({ plugin }) => plugin.name).join(" → ")}`);
1189
1446
  return ordered;
1190
1447
  }
1191
- /** Проверяет, можно ли удалить плагин, не нарушив обязательные зависимости. @internal */
1448
+ /** Проверяет, можно ли удалить плагин без нарушения обязательных зависимостей. @internal */
1192
1449
  function assertPluginRemovable(plugins, name) {
1193
1450
  const dependent = plugins.find((plugin) => plugin.requires?.includes(name));
1194
1451
  if (dependent) throw new require_storage.ItdConfigError(`нельзя отключить плагин «${name}»: от него зависит «${dependent.name}»`);
1195
1452
  }
1196
- const REQUEST_HOOK_DISPATCHERS = /* @__PURE__ */ new WeakMap();
1197
- const PLUGIN_HOOK_SCOPE = Symbol("itd-api.plugin-hooks");
1198
- /**
1199
- * Вызывает публичный хук, сохраняя привязанный к логическому запросу снимок плагинов.
1200
- *
1201
- * Обычные наборы хуков по-прежнему получают только публичный контекст. Дополнительный
1202
- * аргумент используется исключительно внутренним составным набором PluginRegistry.
1203
- *
1204
- * @internal
1205
- */
1206
- async function dispatchRequestHook(hooks, field, context, request) {
1207
- const dispatcher = REQUEST_HOOK_DISPATCHERS.get(hooks);
1208
- if (dispatcher) {
1209
- await dispatcher(field, context, request);
1210
- return;
1211
- }
1212
- const hook = hooks[field];
1213
- await hook?.(context);
1214
- }
1453
+ //#endregion
1454
+ //#region src/core/plugins/registry.ts
1455
+ const NO_KEYS = /* @__PURE__ */ new Set();
1215
1456
  /**
1216
1457
  * Список подключённых плагинов и собранная из них цепочка обёрток.
1217
1458
  *
@@ -1269,7 +1510,7 @@ var PluginRegistry = class {
1269
1510
  transformers.push(transformer);
1270
1511
  },
1271
1512
  useHooks: (value) => {
1272
- validateHooks(plugin.name, value);
1513
+ validatePluginHooks(plugin.name, value);
1273
1514
  hooks.push({ ...value });
1274
1515
  }
1275
1516
  });
@@ -1349,293 +1590,62 @@ var PluginRegistry = class {
1349
1590
  *
1350
1591
  * Возвращённый объект динамический: подключение и отключение плагина начинает действовать
1351
1592
  * со следующего логического запроса без пересоздания транспорта.
1352
- */
1353
- hooks(base) {
1354
- const hooks = {};
1355
- REQUEST_HOOK_DISPATCHERS.set(hooks, ((field, context, request) => this.#runHook(field, context, request, base)));
1356
- return hooks;
1357
- }
1358
- /**
1359
- * Прогоняет запрос через цепочку обёрток.
1360
- *
1361
- * Снимок цепочки берётся в начале: `unuse()` влияет на новые запросы, но не обрывает
1362
- * уже выполняющийся посередине.
1363
- *
1364
- * @param execute настоящий запрос, вызывается самой внутренней обёрткой
1365
- */
1366
- async run(request, execute) {
1367
- const entries = [...this.#ordered];
1368
- for (const entry of entries) entry.activeRequests += 1;
1369
- const hookScope = entries.flatMap((entry) => entry.hooks);
1370
- const scoped = (current) => current[PLUGIN_HOOK_SCOPE] === hookScope ? current : {
1371
- ...current,
1372
- [PLUGIN_HOOK_SCOPE]: hookScope
1373
- };
1374
- const chain = entries.flatMap((entry) => entry.transformers).reduceRight((next, transformer) => (current) => transformer(scoped(current), (prepared) => next(scoped(prepared))), (current) => execute(scoped(current)));
1375
- try {
1376
- return await chain(scoped(request));
1377
- } finally {
1378
- for (const entry of entries) {
1379
- entry.activeRequests -= 1;
1380
- if (entry.activeRequests === 0) {
1381
- entry.finishDrain?.();
1382
- entry.finishDrain = void 0;
1383
- entry.drain = void 0;
1384
- }
1385
- }
1386
- }
1387
- }
1388
- #rebuildOptionKeys() {
1389
- this.#optionKeys.clear();
1390
- for (const { plugin } of this.#ordered) for (const key of plugin.optionKeys ?? []) this.#optionKeys.add(key);
1391
- }
1392
- #waitForDrain(entry) {
1393
- if (entry.activeRequests === 0) return Promise.resolve();
1394
- entry.drain ??= new Promise((resolve) => {
1395
- entry.finishDrain = resolve;
1396
- });
1397
- return entry.drain;
1398
- }
1399
- #trackCleanup(cleanup) {
1400
- this.#cleanups.add(cleanup);
1401
- cleanup.then(() => this.#cleanups.delete(cleanup), () => this.#cleanups.delete(cleanup));
1402
- return cleanup;
1403
- }
1404
- async #runHook(field, context, request, base) {
1405
- const baseHook = base[field];
1406
- await baseHook?.(context);
1407
- const scope = request[PLUGIN_HOOK_SCOPE] ?? [];
1408
- for (const hooks of scope) {
1409
- const hook = hooks[field];
1410
- await hook?.(context);
1411
- }
1412
- }
1413
- };
1414
- //#endregion
1415
- //#region src/core/retry.ts
1416
- /**
1417
- * Методы, повтор которых безопасен по определению.
1418
- *
1419
- * `DELETE` формально тоже идемпотентен, но его повтор после успеха вернёт `404`
1420
- * и собьёт с толку — в список он не входит.
1421
- */
1422
- const IDEMPOTENT_METHODS = /* @__PURE__ */ new Set([
1423
- "GET",
1424
- "HEAD",
1425
- "OPTIONS"
1426
- ]);
1427
- /**
1428
- * Стоит ли повторять запрос после этой ошибки.
1429
- *
1430
- * Отдельно разобран `429`: он повторяется даже для запросов на запись, потому что
1431
- * гарантирует, что запрос **не был обработан**. Обрыв сети и `5xx` такой гарантии не дают —
1432
- * сервер мог успеть создать пост, — поэтому запись по умолчанию не повторяется.
1433
- */
1434
- function isRetryable(error, method, retryWrites, retryNetworkWrite) {
1435
- if (error instanceof require_storage.ItdAbortError) return false;
1436
- const safeToRepeat = retryWrites || IDEMPOTENT_METHODS.has(method);
1437
- if (error instanceof require_storage.ItdApiError) {
1438
- if (error.status === 429) return true;
1439
- if (error.status >= 500) return safeToRepeat;
1440
- return false;
1441
- }
1442
- if (error instanceof require_storage.ItdNetworkError || error instanceof require_storage.ItdTimeoutError) return safeToRepeat || retryNetworkWrite;
1443
- if (error instanceof require_storage.ItdFileError) return error.retryable && (safeToRepeat || retryNetworkWrite);
1444
- return false;
1445
- }
1446
- /** Экспоненциальная пауза со случайным разбросом. */
1447
- function backoffDelay(attempt, options, random) {
1448
- const exponential = options.baseDelay * 2 ** (attempt - 1);
1449
- const capped = Math.min(exponential, options.maxDelay);
1450
- const spread = capped * options.jitter * (random() * 2 - 1);
1451
- return Math.max(0, Math.round(capped + spread));
1452
- }
1453
- /**
1454
- * Собирает планировщик повторов для транспорта.
1455
- *
1456
- * Поведение при `Retry-After`: пауза, названная сервером, соблюдается точно — она
1457
- * авторитетнее нашего расчёта. Но если сервер просит ждать дольше, чем `maxDelay`,
1458
- * повтор **не выполняется вовсе**: молча спать десять минут внутри вызова библиотека
1459
- * не должна, лучше отдать {@link ItdRateLimitError} и дать решить вызывающему коду.
1460
- *
1461
- * @param options настройки повторов после подстановки значений по умолчанию
1462
- * @param random источник случайности; подменяется в тестах ради предсказуемости
1463
- *
1464
- * @example
1465
- * ```ts
1466
- * const scheduler = createRetryScheduler(config.retry);
1467
- * const delay = scheduler(error, 1, 'GET'); // 500 мс ± 30%
1468
- * ```
1469
- */
1470
- function createRetryScheduler(options, random = Math.random) {
1471
- return (error, attempt, method, retryNetworkWrite = false) => {
1472
- if (attempt >= options.attempts) return void 0;
1473
- if (options.shouldRetry) return options.shouldRetry(error, attempt) ? backoffDelay(attempt, options, random) : void 0;
1474
- if (!isRetryable(error, method, options.retryWrites, retryNetworkWrite)) return void 0;
1475
- if (error instanceof require_storage.ItdApiError && error.retryAfter !== void 0) return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
1476
- return backoffDelay(attempt, options, random);
1477
- };
1478
- }
1479
- //#endregion
1480
- //#region src/core/middleware.ts
1481
- /** Ожидание повтора, которое уважает отмену запроса. */
1482
- function sleep(clock, ms, signal) {
1483
- if (!signal) return new Promise((resolve) => clock.schedule(resolve, ms));
1484
- if (signal.aborted) return Promise.reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1485
- return new Promise((resolve, reject) => {
1486
- const cancel = clock.schedule(() => {
1487
- signal.removeEventListener("abort", onAbort);
1488
- resolve();
1489
- }, ms);
1490
- const onAbort = () => {
1491
- cancel();
1492
- reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
1493
- };
1494
- signal.addEventListener("abort", onAbort, { once: true });
1495
- });
1496
- }
1497
- /**
1498
- * Слой очереди: ограничение конкурентности и частоты.
1499
- *
1500
- * `skipQueue` пропускает запрос мимо очереди — так поступают служебные запросы, которые
1501
- * порождены изнутри другого запроса и не могут ждать освободившегося слота.
1502
- *
1503
- * Очередь выбирается по запросу: у каждого сервиса платформы свой хост и свой лимит.
1504
- */
1505
- function createQueueMiddleware(schedule) {
1506
- return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
1507
- }
1508
- /**
1509
- * Слой плагинов.
1510
- *
1511
- * Стоит снаружи повторов и внутри очереди: плагин должен увидеть запрос и ответ по одному
1512
- * разу, независимо от числа попыток, — иначе, например, текст поста зашифруется дважды.
1513
- */
1514
- function createPluginsMiddleware(plugins) {
1515
- return (request, next) => plugins.run(request, next);
1516
- }
1517
- /**
1518
- * Слой сервисов.
1519
- *
1520
- * Запросу с полем `service` подставляет хост сервиса, его заголовки и `skipAuth`, если
1521
- * сервис объявлен публичным. Заданный у запроса `baseUrl` не трогает.
1522
- *
1523
- * Стоит снаружи повторов и авторизации, чтобы выставленный здесь `skipAuth` был ей виден.
1524
- */
1525
- function createServicesMiddleware(registry) {
1526
- return async (request, next) => {
1527
- const service = request.service === void 0 ? void 0 : registry.require(request.service);
1528
- let prepared = request;
1529
- if (request.baseUrl !== void 0) {
1530
- const baseUrl = normalizeBaseUrl(request.baseUrl);
1531
- if (baseUrl !== request.baseUrl) prepared = {
1532
- ...prepared,
1533
- baseUrl
1534
- };
1535
- if (!(service?.baseUrl === baseUrl ? service.auth !== false : registry.isPrimarySite(baseUrl)) && prepared.skipAuth === void 0) prepared = {
1536
- ...prepared,
1537
- skipAuth: true
1538
- };
1539
- }
1540
- if (!service) return next(prepared);
1541
- if (prepared.baseUrl === void 0) prepared = {
1542
- ...prepared,
1543
- baseUrl: service.baseUrl
1544
- };
1545
- if (service.headers) prepared = withLayerHeaders(prepared, service.headers);
1546
- if (service.auth === false && prepared.skipAuth === void 0) prepared = {
1547
- ...prepared,
1548
- skipAuth: true
1549
- };
1550
- return next(prepared);
1551
- };
1552
- }
1553
- async function applyAuth(request, deps) {
1554
- if (request.skipAuth) return request;
1555
- const headers = await deps.getAuthHeaders();
1556
- return Object.keys(headers).length > 0 ? withLayerHeaders(request, headers) : request;
1557
- }
1558
- /**
1559
- * Слой авторизации.
1560
- *
1561
- * Подставляет заголовок `Authorization` и обрабатывает `401`: обновляет токен и повторяет
1562
- * запрос ровно один раз. Стоит внутри повторов, поэтому обычным попыткам он не виден —
1563
- * они уже работают со свежим токеном.
1564
- */
1565
- function createAuthMiddleware(deps) {
1566
- return async (request, next) => {
1567
- const authorized = await applyAuth(request, deps);
1593
+ */
1594
+ hooks(base) {
1595
+ return createRequestHooks((field, context, request) => this.#runHook(field, context, request, base));
1596
+ }
1597
+ /**
1598
+ * Прогоняет запрос через цепочку обёрток.
1599
+ *
1600
+ * Снимок цепочки берётся в начале: `unuse()` влияет на новые запросы, но не обрывает
1601
+ * уже выполняющийся посередине.
1602
+ *
1603
+ * @param execute настоящий запрос, вызывается самой внутренней обёрткой
1604
+ */
1605
+ async run(request, execute) {
1606
+ const entries = [...this.#ordered];
1607
+ for (const entry of entries) entry.activeRequests += 1;
1608
+ const hookScope = entries.flatMap((entry) => entry.hooks);
1609
+ const scoped = (current) => withRequestHookScope(current, hookScope);
1610
+ const chain = entries.flatMap((entry) => entry.transformers).reduceRight((next, transformer) => (current) => transformer(scoped(current), (prepared) => next(scoped(prepared))), (current) => execute(scoped(current)));
1568
1611
  try {
1569
- return await next(authorized);
1570
- } catch (error) {
1571
- if (request.skipAuthRefresh || !deps.autoRefresh || !require_storage.isItdApiError(error) || error.status !== 401) throw error;
1572
- if (!await deps.onUnauthorized()) throw error;
1573
- return next(await applyAuth({
1574
- ...request,
1575
- skipAuthRefresh: true
1576
- }, deps));
1577
- }
1578
- };
1579
- }
1580
- /**
1581
- * Выбирает планировщик отката для конкретного запроса.
1582
- *
1583
- * `retry` у запроса переопределяет глобальную настройку: `false` выключает повторы,
1584
- * объект задаёт свои. Обработка `429` от этого не зависит — она общая.
1585
- */
1586
- function resolveBackoff(retry, global) {
1587
- if (retry === void 0) return global;
1588
- if (retry === false) return void 0;
1589
- const resolved = resolveRetry(retry);
1590
- return resolved ? createRetryScheduler(resolved) : void 0;
1591
- }
1592
- /**
1593
- * Слой повторов.
1594
- *
1595
- * Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
1596
- * всей очереди; сетевые сбои и `5xx` — экспоненциальным откатом. Настройка `retry`
1597
- * у отдельного запроса имеет приоритет над глобальной.
1598
- */
1599
- function createRetryMiddleware(deps) {
1600
- const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
1601
- const nextDelay = (error, attempt, request, method, backoff) => {
1602
- if (require_storage.isItdRateLimitError(error)) {
1603
- const wait = error.retryAfter ?? deps.rateLimitDelays[attempt - 1];
1604
- if (wait === void 0) return void 0;
1605
- deps.pauseQueue?.(wait, request);
1606
- deps.logger?.debug(`лимит частоты, попытка ${attempt + 1} через ${wait} мс`);
1607
- return wait;
1612
+ return await chain(scoped(request));
1613
+ } finally {
1614
+ for (const entry of entries) {
1615
+ entry.activeRequests -= 1;
1616
+ if (entry.activeRequests === 0) {
1617
+ entry.finishDrain?.();
1618
+ entry.finishDrain = void 0;
1619
+ entry.drain = void 0;
1620
+ }
1621
+ }
1608
1622
  }
1609
- return backoff?.(error, attempt, method, request.retryNetworkWrite ?? false);
1610
- };
1611
- return async (request, next) => {
1612
- const method = request.method.toUpperCase();
1613
- const backoff = resolveBackoff(request.retry, globalScheduler);
1614
- for (let attempt = 1;; attempt++) try {
1615
- return await next({
1616
- ...request,
1617
- attempt
1618
- });
1619
- } catch (error) {
1620
- const delay = nextDelay(error, attempt, request, method, backoff);
1621
- if (delay === void 0) throw error;
1622
- await dispatchRequestHook(deps.hooks, "onRetry", {
1623
- method,
1624
- path: request.path,
1625
- url: deps.buildUrl(request),
1626
- headers: new Headers({
1627
- ...request.layerHeaders,
1628
- ...request.headers
1629
- }),
1630
- attempt,
1631
- error,
1632
- delay
1633
- }, request);
1634
- deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${attempt + 1} через ${delay} мс`);
1635
- await sleep(deps.clock ?? systemClock, delay, request.signal);
1623
+ }
1624
+ #rebuildOptionKeys() {
1625
+ this.#optionKeys.clear();
1626
+ for (const { plugin } of this.#ordered) for (const key of plugin.optionKeys ?? []) this.#optionKeys.add(key);
1627
+ }
1628
+ #waitForDrain(entry) {
1629
+ if (entry.activeRequests === 0) return Promise.resolve();
1630
+ entry.drain ??= new Promise((resolve) => {
1631
+ entry.finishDrain = resolve;
1632
+ });
1633
+ return entry.drain;
1634
+ }
1635
+ #trackCleanup(cleanup) {
1636
+ this.#cleanups.add(cleanup);
1637
+ cleanup.then(() => this.#cleanups.delete(cleanup), () => this.#cleanups.delete(cleanup));
1638
+ return cleanup;
1639
+ }
1640
+ async #runHook(field, context, request, base) {
1641
+ const baseHook = base[field];
1642
+ await baseHook?.(context);
1643
+ for (const hooks of requestHookScope(request)) {
1644
+ const hook = hooks[field];
1645
+ await hook?.(context);
1636
1646
  }
1637
- };
1638
- }
1647
+ }
1648
+ };
1639
1649
  //#endregion
1640
1650
  //#region src/core/rate-limit.ts
1641
1651
  /** Ошибка отмены запроса, который ещё не дошёл до транспорта. */
@@ -4925,6 +4935,140 @@ var CommentsResource = class extends BaseResource {
4925
4935
  }
4926
4936
  };
4927
4937
  //#endregion
4938
+ //#region src/core/attachments/url-source.ts
4939
+ /** Убирает параметры MIME и приводит его к форме для сравнения. */
4940
+ function normalizeMimeType(contentType) {
4941
+ return contentType?.split(";", 1)[0]?.trim().toLowerCase() || void 0;
4942
+ }
4943
+ /** Достаёт имя файла из пути URL. */
4944
+ function filenameFromUrl(url) {
4945
+ const last = url.pathname.split("/").pop();
4946
+ if (!last) return void 0;
4947
+ try {
4948
+ return decodeURIComponent(last) || void 0;
4949
+ } catch {
4950
+ return last;
4951
+ }
4952
+ }
4953
+ /** Проверяет объявленный размер и возвращает его, если заголовок корректен. */
4954
+ function declaredSize(response, maxBytes, url) {
4955
+ const header = response.headers.get("content-length");
4956
+ if (header === null) return void 0;
4957
+ const size = Number(header);
4958
+ if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) return void 0;
4959
+ if (maxBytes !== void 0 && size > maxBytes) throw require_multi_storage.fileTooLarge(url, maxBytes, size);
4960
+ return size;
4961
+ }
4962
+ /** Получает HTTP-ответ источника и проверяет его статус. */
4963
+ async function fetchFile(target, options, context) {
4964
+ let requested;
4965
+ try {
4966
+ requested = new URL(target);
4967
+ } catch {
4968
+ throw new require_storage.ItdConfigError(`«${target}» не разбирается как адрес`);
4969
+ }
4970
+ if (requested.protocol !== "http:" && requested.protocol !== "https:") throw new require_storage.ItdConfigError(`вложение по адресу поддерживает только http и https, получено: ${requested.protocol}`);
4971
+ let response;
4972
+ try {
4973
+ response = await context.fetch(requested, { ...context.signal ? { signal: context.signal } : {} });
4974
+ } catch (error) {
4975
+ if (context.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
4976
+ throw new require_storage.ItdFileError(`не удалось получить файл по адресу ${requested.href}`, {
4977
+ reason: require_storage.ItdFileErrorReason.Network,
4978
+ url: requested.href,
4979
+ retryable: true,
4980
+ cause: error
4981
+ });
4982
+ }
4983
+ const finalUrl = response.url ? new URL(response.url) : requested;
4984
+ if (!response.ok) {
4985
+ await response.body?.cancel().catch(() => {});
4986
+ throw new require_storage.ItdFileError(`источник ${finalUrl.href} ответил статусом ${response.status}`, {
4987
+ reason: require_storage.ItdFileErrorReason.Http,
4988
+ url: finalUrl.href,
4989
+ status: response.status,
4990
+ retryable: response.status === 408 || response.status === 429 || response.status >= 500
4991
+ });
4992
+ }
4993
+ const { maxBytes } = require_multi_storage.resolveFileStreamOptions(options, require_multi_storage.DEFAULT_URL_FILE_MAX_BYTES);
4994
+ let size;
4995
+ try {
4996
+ size = declaredSize(response, maxBytes, finalUrl.href);
4997
+ } catch (error) {
4998
+ await response.body?.cancel().catch(() => {});
4999
+ throw error;
5000
+ }
5001
+ return {
5002
+ response,
5003
+ url: finalUrl,
5004
+ size
5005
+ };
5006
+ }
5007
+ /** Читает ответ с контролем размера до создания итогового `Blob`. */
5008
+ async function responseBlob(response, url, maxBytes, streamBufferBytes, signal) {
5009
+ if (!response.body) {
5010
+ const blob = await response.blob();
5011
+ if (maxBytes !== void 0 && blob.size > maxBytes) throw require_multi_storage.fileTooLarge(url, maxBytes, blob.size);
5012
+ return blob;
5013
+ }
5014
+ const chunks = [];
5015
+ const reader = require_multi_storage.boundedFileStream(response.body, {
5016
+ ...maxBytes !== void 0 ? { maxBytes } : {},
5017
+ streamBufferBytes,
5018
+ ...signal ? { signal } : {},
5019
+ url,
5020
+ retryableRead: true
5021
+ }).getReader();
5022
+ try {
5023
+ for (;;) {
5024
+ const next = await reader.read();
5025
+ if (next.done) break;
5026
+ chunks.push(next.value);
5027
+ }
5028
+ } finally {
5029
+ reader.releaseLock();
5030
+ }
5031
+ return new Blob(chunks.map((chunk) => Uint8Array.from(chunk).buffer));
5032
+ }
5033
+ /** Скачивает файл целиком с ограничением размера. @internal */
5034
+ async function downloadFile(target, options, context) {
5035
+ const resolved = require_multi_storage.resolveFileStreamOptions(options, require_multi_storage.DEFAULT_URL_FILE_MAX_BYTES);
5036
+ const { response, url } = await fetchFile(target, options, context);
5037
+ const blob = await responseBlob(response, url.href, resolved.maxBytes, resolved.streamBufferBytes, context.signal);
5038
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
5039
+ const filename = options.filename ?? filenameFromUrl(url);
5040
+ return {
5041
+ file: new Blob([blob], { type: contentType ?? "" }),
5042
+ ...filename ? { filename } : {},
5043
+ ...contentType ? { contentType } : {}
5044
+ };
5045
+ }
5046
+ /** Открывает HTTP-ответ как ограниченный поток. @internal */
5047
+ async function openUrlFile(target, options, context) {
5048
+ const resolved = require_multi_storage.resolveFileStreamOptions(options, require_multi_storage.DEFAULT_URL_FILE_MAX_BYTES);
5049
+ const { response, url, size } = await fetchFile(target, options, context);
5050
+ if (!response.body) throw new require_storage.ItdFileError(`источник ${url.href} не предоставил потоковое тело`, {
5051
+ reason: require_storage.ItdFileErrorReason.StreamUnavailable,
5052
+ url: url.href
5053
+ });
5054
+ const stream = require_multi_storage.boundedFileStream(response.body, {
5055
+ ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
5056
+ streamBufferBytes: resolved.streamBufferBytes,
5057
+ ...context.signal ? { signal: context.signal } : {},
5058
+ url: url.href,
5059
+ retryableRead: true
5060
+ });
5061
+ const filename = options.filename ?? filenameFromUrl(url);
5062
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
5063
+ return {
5064
+ stream,
5065
+ ...filename ? { filename } : {},
5066
+ ...contentType ? { contentType } : {},
5067
+ ...size !== void 0 ? { size } : {},
5068
+ close: () => stream.cancel().catch(() => {})
5069
+ };
5070
+ }
5071
+ //#endregion
4928
5072
  //#region src/core/mime.ts
4929
5073
  /** Изображения, которые принимает `POST /api/files/upload`. */
4930
5074
  const IMAGE_MIME_TYPES = Object.freeze([
@@ -5173,7 +5317,7 @@ var FilesResource = class extends BaseResource {
5173
5317
  async #prepareBuffer(input, options, context) {
5174
5318
  const content = await this.#resolveBuffer(input, context);
5175
5319
  const filename = options.filename ?? content.filename ?? this.#nameFromMime(options.contentType ?? content.contentType);
5176
- const contentType = require_multi_storage.normalizeMimeType(options.contentType ?? content.contentType) ?? require_multi_storage.normalizeMimeType(require_runtime.isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
5320
+ const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(require_runtime.isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
5177
5321
  if (options.validateMime !== false) assertAllowedMime(contentType, filename);
5178
5322
  const blob = require_runtime.isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
5179
5323
  const limits = require_multi_storage.resolveFileStreamOptions(options);
@@ -5194,7 +5338,7 @@ var FilesResource = class extends BaseResource {
5194
5338
  if (typeof input === "object" && input !== null && "open" in input) opened = await input.open(context);
5195
5339
  else if (typeof input === "object" && input !== null && "url" in input) {
5196
5340
  const { url, ...urlOptions } = input;
5197
- opened = await require_multi_storage.openUrlFile(url, urlOptions, context);
5341
+ opened = await openUrlFile(url, urlOptions, context);
5198
5342
  } else throw new require_storage.ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
5199
5343
  if (!opened || typeof ReadableStream === "undefined" || !(opened.stream instanceof ReadableStream)) {
5200
5344
  await opened?.close?.();
@@ -5202,7 +5346,7 @@ var FilesResource = class extends BaseResource {
5202
5346
  }
5203
5347
  try {
5204
5348
  const filename = options.filename ?? opened.filename ?? this.#nameFromMime(options.contentType ?? opened.contentType);
5205
- const contentType = require_multi_storage.normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
5349
+ const contentType = normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
5206
5350
  if (options.validateMime !== false) assertAllowedMime(contentType, filename);
5207
5351
  const limits = require_multi_storage.resolveFileStreamOptions({
5208
5352
  mode: require_multi_storage.FileTransferMode.Stream,
@@ -5240,7 +5384,7 @@ var FilesResource = class extends BaseResource {
5240
5384
  if ("load" in input) return input.load(context);
5241
5385
  if ("url" in input) {
5242
5386
  const { url, ...urlOptions } = input;
5243
- return require_multi_storage.downloadFile(url, urlOptions, context);
5387
+ return downloadFile(url, urlOptions, context);
5244
5388
  }
5245
5389
  if ("file" in input) return input;
5246
5390
  if ("open" in input) throw new require_storage.ItdConfigError("потоковый источник нельзя использовать как буферный");
@@ -5248,7 +5392,7 @@ var FilesResource = class extends BaseResource {
5248
5392
  }
5249
5393
  /** Подбирает непустое имя, обязательное для multipart. */
5250
5394
  #nameFromMime(contentType) {
5251
- const extension = require_multi_storage.normalizeMimeType(contentType)?.split("/")[1];
5395
+ const extension = normalizeMimeType(contentType)?.split("/")[1];
5252
5396
  return extension ? `file.${extension}` : "file";
5253
5397
  }
5254
5398
  };
@@ -5470,7 +5614,6 @@ var NotificationsResource = class extends BaseResource {
5470
5614
  };
5471
5615
  //#endregion
5472
5616
  //#region src/core/time.ts
5473
- /** Отметка времени без часового пояса: `2026-07-23 23:14:25`, возможно с долями секунды. */
5474
5617
  const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
5475
5618
  /**
5476
5619
  * Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC.
@@ -5489,6 +5632,21 @@ function utcStampToIso(value) {
5489
5632
  const iso = `${match[1]}T${match[2]}Z`;
5490
5633
  return Number.isFinite(Date.parse(iso)) ? iso : value;
5491
5634
  }
5635
+ /**
5636
+ * Разбирает дату API в объект `Date`.
5637
+ *
5638
+ * @returns `null`, если строки нет или она не разбирается
5639
+ *
5640
+ * @example
5641
+ * ```ts
5642
+ * const created = toDate(post.createdAt);
5643
+ * ```
5644
+ */
5645
+ function toDate(value) {
5646
+ if (!value) return null;
5647
+ const date = new Date(value);
5648
+ return Number.isFinite(date.getTime()) ? date : null;
5649
+ }
5492
5650
  //#endregion
5493
5651
  //#region src/resources/platform.ts
5494
5652
  /** Приводит `last_checked` каждого сервиса к ISO. Остальное остаётся как прислал сервер. */
@@ -6419,7 +6577,7 @@ function resolvePoll(input) {
6419
6577
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6420
6578
  const BUILD_UPDATE = Symbol.for("itd.postBuilder.update");
6421
6579
  /**
6422
- * Проверяет данные поста.
6580
+ * Проверяет и нормализует данные поста.
6423
6581
  *
6424
6582
  * Отдельного внимания заслуживает `wallRecipientId`: API принимает там **только UUID**,
6425
6583
  * а имя пользователя молча приводит к ошибке на сервере. Проверка здесь превращает
@@ -6439,10 +6597,11 @@ function validatePost(input) {
6439
6597
  if (wallRecipientId !== void 0 && wallRecipientId !== null) {
6440
6598
  if (!UUID_PATTERN.test(wallRecipientId)) throw new require_storage.ItdConfigError(`wallRecipientId должен быть UUID, а не именем пользователя (получено: «${wallRecipientId}»). Идентификатор можно взять из профиля: (await itd.users.get(username)).id`);
6441
6599
  }
6600
+ const { poll: inputPoll, ...data } = input;
6442
6601
  return {
6443
- ...input,
6602
+ ...data,
6444
6603
  ...input.spans !== void 0 ? { spans: validateSpans(content, input.spans) } : {},
6445
- ...input.poll !== void 0 ? { poll: resolvePoll(input.poll) } : {}
6604
+ ...inputPoll !== void 0 ? { poll: resolvePoll(inputPoll) } : {}
6446
6605
  };
6447
6606
  }
6448
6607
  /**
@@ -6639,7 +6798,8 @@ function post(content) {
6639
6798
  }
6640
6799
  /** Приводит любую форму входа к готовым данным поста. */
6641
6800
  function resolvePost(input) {
6642
- return resolveInput(input, () => post(), validatePost);
6801
+ const resolved = typeof input === "function" ? input(post()) : input;
6802
+ return isBuilder(resolved) ? validatePost(resolved.build()) : validatePost(resolved);
6643
6803
  }
6644
6804
  function validatePostUpdate(input) {
6645
6805
  if (!input || typeof input !== "object") throw new require_storage.ItdConfigError("Для обновления поста нужен объект с явно заданным content");
@@ -8651,6 +8811,92 @@ function createAccounts(options = {}) {
8651
8811
  return new ItdAccounts(options);
8652
8812
  }
8653
8813
  //#endregion
8814
+ //#region src/core/attachments/factories.ts
8815
+ function fromUrl(url, options = {}) {
8816
+ if (require_multi_storage.resolveFileStreamOptions(options, 104857600).mode === require_multi_storage.FileTransferMode.Stream) return { open: (context) => openUrlFile(url, options, context) };
8817
+ return { load: (context) => downloadFile(url, options, context) };
8818
+ }
8819
+ /**
8820
+ * Создаёт повторяемый пользовательский поток.
8821
+ *
8822
+ * Фабрика вызывается заново для каждой попытки; возвращать один и тот же поток нельзя.
8823
+ */
8824
+ function fromStream(factory, options = {}) {
8825
+ const resolved = require_multi_storage.resolveFileStreamOptions({
8826
+ ...options,
8827
+ mode: require_multi_storage.FileTransferMode.Stream
8828
+ }, void 0);
8829
+ require_multi_storage.optionalBytes(options.size, "size");
8830
+ return { open: async (context) => {
8831
+ let opened;
8832
+ try {
8833
+ opened = await factory(context);
8834
+ } catch (error) {
8835
+ if (error instanceof require_storage.ItdFileError || error instanceof require_storage.ItdConfigError || context.signal?.aborted) throw error;
8836
+ throw new require_storage.ItdFileError("не удалось открыть поток вложения", {
8837
+ reason: require_storage.ItdFileErrorReason.Read,
8838
+ retryable: true,
8839
+ cause: error
8840
+ });
8841
+ }
8842
+ const content = require_multi_storage.isReadableByteStream(opened) ? { stream: opened } : opened;
8843
+ if (!content || !require_multi_storage.isReadableByteStream(content.stream)) throw new require_storage.ItdConfigError("fromStream должен вернуть ReadableStream или { stream }");
8844
+ const size = options.size ?? content.size;
8845
+ require_multi_storage.optionalBytes(size, "size");
8846
+ if (resolved.maxBytes !== void 0 && size !== void 0 && size > resolved.maxBytes) {
8847
+ await content.close?.();
8848
+ throw require_multi_storage.fileTooLarge(void 0, resolved.maxBytes, size);
8849
+ }
8850
+ const contentType = normalizeMimeType(options.contentType ?? content.contentType);
8851
+ return {
8852
+ stream: require_multi_storage.boundedFileStream(content.stream, {
8853
+ ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
8854
+ streamBufferBytes: resolved.streamBufferBytes,
8855
+ ...context.signal ? { signal: context.signal } : {},
8856
+ retryableRead: true
8857
+ }),
8858
+ ...options.filename ?? content.filename ? { filename: options.filename ?? content.filename } : {},
8859
+ ...contentType ? { contentType } : {},
8860
+ ...size !== void 0 ? { size } : {},
8861
+ ...content.close ? { close: content.close } : {}
8862
+ };
8863
+ } };
8864
+ }
8865
+ //#endregion
8866
+ //#region src/models/guards.ts
8867
+ /**
8868
+ * Свой ли это профиль.
8869
+ *
8870
+ * @example
8871
+ * ```ts
8872
+ * if (isMyProfile(profile)) console.log(profile.subscription.isActive);
8873
+ * ```
8874
+ */
8875
+ function isMyProfile(profile) {
8876
+ return "subscription" in profile;
8877
+ }
8878
+ //#endregion
8879
+ //#region src/models/status-helpers.ts
8880
+ const STATUS_WINDOW_DAYS = 90;
8881
+ /**
8882
+ * Разворачивает историю сервиса в массив на 90 суток.
8883
+ * Сутки без данных становятся `null`.
8884
+ *
8885
+ * @returns массив, где индекс — сколько суток назад: `[0]` — сегодня
8886
+ *
8887
+ * @example
8888
+ * ```ts
8889
+ * const status = await itd.platform.status();
8890
+ * const days = statusDays(status.services[0]);
8891
+ *
8892
+ * days[0]?.uptime; // доступность за сегодня
8893
+ * days.filter((day) => day === null).length; // за сколько суток данных нет
8894
+ * ```
8895
+ */
8896
+ function statusDays(service) {
8897
+ return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
8898
+ }
8899
+ //#endregion
8654
8900
  //#region src/notifications/text.ts
8655
8901
  /** Имя, которое подставляется, если участник неизвестен. */
8656
8902
  const UNKNOWN_ACTOR = "Пользователь";
@@ -9067,54 +9313,6 @@ function renderSpans(content, spans = [], options = {}) {
9067
9313
  return result;
9068
9314
  }
9069
9315
  //#endregion
9070
- //#region src/types/models.ts
9071
- /**
9072
- * Свой ли это профиль.
9073
- *
9074
- * @example
9075
- * ```ts
9076
- * if (isMyProfile(profile)) console.log(profile.subscription.isActive);
9077
- * ```
9078
- */
9079
- function isMyProfile(profile) {
9080
- return "subscription" in profile;
9081
- }
9082
- /** Глубина истории статуса в сутках. Столько элементов отдаёт {@link statusDays}. */
9083
- const STATUS_WINDOW_DAYS = 90;
9084
- /**
9085
- * Разбирает дату API в объект `Date`.
9086
- *
9087
- * @returns `null`, если строки нет или она не разбирается
9088
- *
9089
- * @example
9090
- * ```ts
9091
- * const created = toDate(post.createdAt);
9092
- * ```
9093
- */
9094
- function toDate(value) {
9095
- if (!value) return null;
9096
- const date = new Date(value);
9097
- return Number.isFinite(date.getTime()) ? date : null;
9098
- }
9099
- /**
9100
- * Разворачивает историю сервиса в массив на 90 суток.
9101
- * Сутки без данных становятся `null`.
9102
- *
9103
- * @returns массив, где индекс — сколько суток назад: `[0]` — сегодня
9104
- *
9105
- * @example
9106
- * ```ts
9107
- * const status = await itd.platform.status();
9108
- * const days = statusDays(status.services[0]);
9109
- *
9110
- * days[0]?.uptime; // доступность за сегодня
9111
- * days.filter((day) => day === null).length; // за сколько суток данных нет
9112
- * ```
9113
- */
9114
- function statusDays(service) {
9115
- return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
9116
- }
9117
- //#endregion
9118
9316
  exports.ALLOWED_MIME_TYPES = ALLOWED_MIME_TYPES;
9119
9317
  exports.AUDIO_MIME_TYPES = AUDIO_MIME_TYPES;
9120
9318
  exports.AUTH_FLAG_COOKIE = require_multi_storage.AUTH_FLAG_COOKIE;
@@ -9203,8 +9401,8 @@ exports.createMultiTokenStorage = require_multi_storage.createMultiTokenStorage;
9203
9401
  exports.createRecordMultiStorage = require_multi_storage.createRecordMultiStorage;
9204
9402
  exports.createTokenStorage = require_storage.createTokenStorage;
9205
9403
  exports.formatNotificationText = formatNotificationText;
9206
- exports.fromStream = require_multi_storage.fromStream;
9207
- exports.fromUrl = require_multi_storage.fromUrl;
9404
+ exports.fromStream = fromStream;
9405
+ exports.fromUrl = fromUrl;
9208
9406
  exports.isBuilder = isBuilder;
9209
9407
  exports.isItdApiError = require_storage.isItdApiError;
9210
9408
  exports.isItdAuthError = require_storage.isItdAuthError;