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/README.md +3 -2
- package/dist/index.cjs +572 -374
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +833 -791
- package/dist/index.d.ts +833 -791
- package/dist/index.js +565 -367
- package/dist/index.js.map +1 -1
- package/dist/{multi-storage-D1keK2Op.cjs → multi-storage-C8R5Crwo.cjs} +33 -219
- package/dist/multi-storage-C8R5Crwo.cjs.map +1 -0
- package/dist/{multi-storage-CyMe404l.js → multi-storage-D4h6QA-w.js} +26 -200
- package/dist/multi-storage-D4h6QA-w.js.map +1 -0
- package/dist/{multi-storage-NDqzRQcD.d.cts → multi-storage-DP_ujJw1.d.cts} +3 -17
- package/dist/{multi-storage-BhcA2Izn.d.ts → multi-storage-DwaIh24p.d.ts} +3 -17
- package/dist/node.cjs +1 -1
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +1 -1
- package/dist/node.js.map +1 -1
- package/package.json +1 -1
- package/dist/multi-storage-CyMe404l.js.map +0 -1
- package/dist/multi-storage-D1keK2Op.cjs.map +0 -1
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-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
/** Проверяет, можно ли удалить
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
const
|
|
1368
|
-
|
|
1369
|
-
const
|
|
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
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
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
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
const
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
...
|
|
6602
|
+
...data,
|
|
6444
6603
|
...input.spans !== void 0 ? { spans: validateSpans(content, input.spans) } : {},
|
|
6445
|
-
...
|
|
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
|
-
|
|
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 =
|
|
9207
|
-
exports.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;
|