itd-api 0.2.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 +8 -6
- package/dist/index.cjs +1220 -588
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1307 -1079
- package/dist/index.d.ts +1307 -1079
- package/dist/index.js +1210 -582
- 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
|
|
@@ -419,7 +419,7 @@ var AuthManager = class {
|
|
|
419
419
|
await this.#saveSession({
|
|
420
420
|
...this.#session ?? {},
|
|
421
421
|
accessToken,
|
|
422
|
-
obtainedAt:
|
|
422
|
+
obtainedAt: this.#config.clock.now()
|
|
423
423
|
});
|
|
424
424
|
this.#emitter.emit("tokens", { accessToken });
|
|
425
425
|
}
|
|
@@ -504,12 +504,12 @@ var AuthManager = class {
|
|
|
504
504
|
if (!auth) return null;
|
|
505
505
|
if (typeof auth === "string") return {
|
|
506
506
|
accessToken: auth,
|
|
507
|
-
obtainedAt:
|
|
507
|
+
obtainedAt: this.#config.clock.now()
|
|
508
508
|
};
|
|
509
509
|
if ("accessToken" in auth) return {
|
|
510
510
|
accessToken: auth.accessToken,
|
|
511
511
|
refreshToken: auth.refreshToken,
|
|
512
|
-
obtainedAt:
|
|
512
|
+
obtainedAt: this.#config.clock.now()
|
|
513
513
|
};
|
|
514
514
|
return null;
|
|
515
515
|
}
|
|
@@ -557,7 +557,7 @@ var AuthManager = class {
|
|
|
557
557
|
...this.#session ?? {},
|
|
558
558
|
accessToken,
|
|
559
559
|
...rotated ? { refreshToken: rotated } : {},
|
|
560
|
-
obtainedAt:
|
|
560
|
+
obtainedAt: this.#config.clock.now()
|
|
561
561
|
});
|
|
562
562
|
this.#emitter.emit("tokens", { accessToken });
|
|
563
563
|
return accessToken;
|
|
@@ -632,7 +632,7 @@ var AuthManager = class {
|
|
|
632
632
|
this.#transitionAuth(accessToken);
|
|
633
633
|
await this.#saveSession({
|
|
634
634
|
accessToken,
|
|
635
|
-
obtainedAt:
|
|
635
|
+
obtainedAt: this.#config.clock.now()
|
|
636
636
|
});
|
|
637
637
|
this.#emitter.emit("tokens", { accessToken });
|
|
638
638
|
this.#emitter.emit("signIn", { accessToken });
|
|
@@ -640,6 +640,16 @@ var AuthManager = class {
|
|
|
640
640
|
}
|
|
641
641
|
};
|
|
642
642
|
//#endregion
|
|
643
|
+
//#region src/core/clock.ts
|
|
644
|
+
/** Системные часы, используемые клиентом по умолчанию. */
|
|
645
|
+
const systemClock = Object.freeze({
|
|
646
|
+
now: () => Date.now(),
|
|
647
|
+
schedule(callback, delay) {
|
|
648
|
+
const timer = setTimeout(callback, delay);
|
|
649
|
+
return () => clearTimeout(timer);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
//#endregion
|
|
643
653
|
//#region src/core/url.ts
|
|
644
654
|
/**
|
|
645
655
|
* Собирает строку запроса.
|
|
@@ -717,7 +727,7 @@ function normalizeBaseUrl(baseUrl) {
|
|
|
717
727
|
//#endregion
|
|
718
728
|
//#region src/core/version.ts
|
|
719
729
|
/** Версия библиотеки. Попадает в `User-Agent`. */
|
|
720
|
-
const LIBRARY_VERSION = "0.
|
|
730
|
+
const LIBRARY_VERSION = "0.4.0";
|
|
721
731
|
//#endregion
|
|
722
732
|
//#region src/core/config.ts
|
|
723
733
|
/** Базовый URL API итд.com. Домен записан в punycode: `итд.com`. */
|
|
@@ -945,6 +955,7 @@ function resolveConfig(options = {}) {
|
|
|
945
955
|
const mode = options.mode ?? require_runtime.RuntimeMode.Auto;
|
|
946
956
|
if (!Object.values(require_runtime.RuntimeMode).includes(mode)) throw new require_storage.ItdConfigError(`mode должен быть одним из ${Object.values(require_runtime.RuntimeMode).join(", ")}, получено: ${mode}`);
|
|
947
957
|
const timeout = requirePositive(options.timeout ?? 3e4, "timeout");
|
|
958
|
+
if (options.clock !== void 0 && (typeof options.clock !== "object" || options.clock === null || typeof options.clock.now !== "function" || typeof options.clock.schedule !== "function")) throw new require_storage.ItdConfigError("clock должен предоставлять методы now() и schedule()");
|
|
948
959
|
requireOptionalBoolean(options.autoRefresh, "autoRefresh");
|
|
949
960
|
requireOptionalBoolean(options.reloginOnRefreshFailure, "reloginOnRefreshFailure");
|
|
950
961
|
if (options.userAgent !== void 0 && options.userAgent !== false && typeof options.userAgent !== "string") throw new require_storage.ItdConfigError("userAgent должен быть строкой или false");
|
|
@@ -957,6 +968,7 @@ function resolveConfig(options = {}) {
|
|
|
957
968
|
autoRefresh: options.autoRefresh ?? true,
|
|
958
969
|
reloginOnRefreshFailure: options.reloginOnRefreshFailure ?? true,
|
|
959
970
|
fetch: require_runtime.resolveFetch(options.fetch),
|
|
971
|
+
clock: options.clock ?? systemClock,
|
|
960
972
|
timeout,
|
|
961
973
|
retry: resolveRetry(options.retry),
|
|
962
974
|
rateLimit: resolveRateLimit(options.rateLimit),
|
|
@@ -1040,15 +1052,271 @@ function withLayerHeaders(request, headers) {
|
|
|
1040
1052
|
};
|
|
1041
1053
|
}
|
|
1042
1054
|
//#endregion
|
|
1043
|
-
//#region src/core/plugins.ts
|
|
1044
|
-
|
|
1045
|
-
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
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Слой авторизации.
|
|
1239
|
+
*
|
|
1240
|
+
* Подставляет заголовок `Authorization` и обрабатывает `401`: обновляет токен и повторяет
|
|
1241
|
+
* запрос ровно один раз. Стоит внутри повторов, поэтому обычным попыткам он не виден —
|
|
1242
|
+
* они уже работают со свежим токеном.
|
|
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
|
+
}
|
|
1046
1271
|
/**
|
|
1047
|
-
*
|
|
1272
|
+
* Слой повторов.
|
|
1048
1273
|
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1274
|
+
* Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
|
|
1275
|
+
* всей очереди; сетевые сбои и `5xx` — экспоненциальным откатом. Настройка `retry`
|
|
1276
|
+
* у отдельного запроса имеет приоритет над глобальной.
|
|
1051
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
|
|
1052
1320
|
const RESERVED_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
1053
1321
|
"signal",
|
|
1054
1322
|
"timeout",
|
|
@@ -1089,7 +1357,8 @@ function validateNameList(plugin, field) {
|
|
|
1089
1357
|
seen.add(value);
|
|
1090
1358
|
}
|
|
1091
1359
|
}
|
|
1092
|
-
|
|
1360
|
+
/** Проверяет набор hooks, переданный плагином. @internal */
|
|
1361
|
+
function validatePluginHooks(plugin, hooks) {
|
|
1093
1362
|
if (typeof hooks !== "object" || hooks === null) throw new require_storage.ItdConfigError(`плагин «${plugin}» передал в useHooks() не объект`);
|
|
1094
1363
|
for (const field of HOOK_FIELDS) if (hooks[field] !== void 0 && typeof hooks[field] !== "function") throw new require_storage.ItdConfigError(`плагин «${plugin}»: useHooks().${field} должен быть функцией`);
|
|
1095
1364
|
}
|
|
@@ -1176,32 +1445,16 @@ function orderPluginDefinitions(plugins) {
|
|
|
1176
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(" → ")}`);
|
|
1177
1446
|
return ordered;
|
|
1178
1447
|
}
|
|
1179
|
-
/** Проверяет, можно ли удалить
|
|
1448
|
+
/** Проверяет, можно ли удалить плагин без нарушения обязательных зависимостей. @internal */
|
|
1180
1449
|
function assertPluginRemovable(plugins, name) {
|
|
1181
1450
|
const dependent = plugins.find((plugin) => plugin.requires?.includes(name));
|
|
1182
1451
|
if (dependent) throw new require_storage.ItdConfigError(`нельзя отключить плагин «${name}»: от него зависит «${dependent.name}»`);
|
|
1183
1452
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/core/plugins/registry.ts
|
|
1455
|
+
const NO_KEYS = /* @__PURE__ */ new Set();
|
|
1186
1456
|
/**
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
* Обычные наборы хуков по-прежнему получают только публичный контекст. Дополнительный
|
|
1190
|
-
* аргумент используется исключительно внутренним составным набором PluginRegistry.
|
|
1191
|
-
*
|
|
1192
|
-
* @internal
|
|
1193
|
-
*/
|
|
1194
|
-
async function dispatchRequestHook(hooks, field, context, request) {
|
|
1195
|
-
const dispatcher = REQUEST_HOOK_DISPATCHERS.get(hooks);
|
|
1196
|
-
if (dispatcher) {
|
|
1197
|
-
await dispatcher(field, context, request);
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
const hook = hooks[field];
|
|
1201
|
-
await hook?.(context);
|
|
1202
|
-
}
|
|
1203
|
-
/**
|
|
1204
|
-
* Список подключённых плагинов и собранная из них цепочка обёрток.
|
|
1457
|
+
* Список подключённых плагинов и собранная из них цепочка обёрток.
|
|
1205
1458
|
*
|
|
1206
1459
|
* Живёт в клиенте, а работает в транспорте: {@link HttpClient} прогоняет через `run`
|
|
1207
1460
|
* каждый запрос, если плагины есть.
|
|
@@ -1257,7 +1510,7 @@ var PluginRegistry = class {
|
|
|
1257
1510
|
transformers.push(transformer);
|
|
1258
1511
|
},
|
|
1259
1512
|
useHooks: (value) => {
|
|
1260
|
-
|
|
1513
|
+
validatePluginHooks(plugin.name, value);
|
|
1261
1514
|
hooks.push({ ...value });
|
|
1262
1515
|
}
|
|
1263
1516
|
});
|
|
@@ -1339,9 +1592,7 @@ var PluginRegistry = class {
|
|
|
1339
1592
|
* со следующего логического запроса без пересоздания транспорта.
|
|
1340
1593
|
*/
|
|
1341
1594
|
hooks(base) {
|
|
1342
|
-
|
|
1343
|
-
REQUEST_HOOK_DISPATCHERS.set(hooks, ((field, context, request) => this.#runHook(field, context, request, base)));
|
|
1344
|
-
return hooks;
|
|
1595
|
+
return createRequestHooks((field, context, request) => this.#runHook(field, context, request, base));
|
|
1345
1596
|
}
|
|
1346
1597
|
/**
|
|
1347
1598
|
* Прогоняет запрос через цепочку обёрток.
|
|
@@ -1355,10 +1606,7 @@ var PluginRegistry = class {
|
|
|
1355
1606
|
const entries = [...this.#ordered];
|
|
1356
1607
|
for (const entry of entries) entry.activeRequests += 1;
|
|
1357
1608
|
const hookScope = entries.flatMap((entry) => entry.hooks);
|
|
1358
|
-
const scoped = (current) => current
|
|
1359
|
-
...current,
|
|
1360
|
-
[PLUGIN_HOOK_SCOPE]: hookScope
|
|
1361
|
-
};
|
|
1609
|
+
const scoped = (current) => withRequestHookScope(current, hookScope);
|
|
1362
1610
|
const chain = entries.flatMap((entry) => entry.transformers).reduceRight((next, transformer) => (current) => transformer(scoped(current), (prepared) => next(scoped(prepared))), (current) => execute(scoped(current)));
|
|
1363
1611
|
try {
|
|
1364
1612
|
return await chain(scoped(request));
|
|
@@ -1392,239 +1640,13 @@ var PluginRegistry = class {
|
|
|
1392
1640
|
async #runHook(field, context, request, base) {
|
|
1393
1641
|
const baseHook = base[field];
|
|
1394
1642
|
await baseHook?.(context);
|
|
1395
|
-
const
|
|
1396
|
-
for (const hooks of scope) {
|
|
1643
|
+
for (const hooks of requestHookScope(request)) {
|
|
1397
1644
|
const hook = hooks[field];
|
|
1398
1645
|
await hook?.(context);
|
|
1399
1646
|
}
|
|
1400
1647
|
}
|
|
1401
1648
|
};
|
|
1402
1649
|
//#endregion
|
|
1403
|
-
//#region src/core/retry.ts
|
|
1404
|
-
/**
|
|
1405
|
-
* Методы, повтор которых безопасен по определению.
|
|
1406
|
-
*
|
|
1407
|
-
* `DELETE` формально тоже идемпотентен, но его повтор после успеха вернёт `404`
|
|
1408
|
-
* и собьёт с толку — в список он не входит.
|
|
1409
|
-
*/
|
|
1410
|
-
const IDEMPOTENT_METHODS = /* @__PURE__ */ new Set([
|
|
1411
|
-
"GET",
|
|
1412
|
-
"HEAD",
|
|
1413
|
-
"OPTIONS"
|
|
1414
|
-
]);
|
|
1415
|
-
/**
|
|
1416
|
-
* Стоит ли повторять запрос после этой ошибки.
|
|
1417
|
-
*
|
|
1418
|
-
* Отдельно разобран `429`: он повторяется даже для запросов на запись, потому что
|
|
1419
|
-
* гарантирует, что запрос **не был обработан**. Обрыв сети и `5xx` такой гарантии не дают —
|
|
1420
|
-
* сервер мог успеть создать пост, — поэтому запись по умолчанию не повторяется.
|
|
1421
|
-
*/
|
|
1422
|
-
function isRetryable(error, method, retryWrites, retryNetworkWrite) {
|
|
1423
|
-
if (error instanceof require_storage.ItdAbortError) return false;
|
|
1424
|
-
const safeToRepeat = retryWrites || IDEMPOTENT_METHODS.has(method);
|
|
1425
|
-
if (error instanceof require_storage.ItdApiError) {
|
|
1426
|
-
if (error.status === 429) return true;
|
|
1427
|
-
if (error.status >= 500) return safeToRepeat;
|
|
1428
|
-
return false;
|
|
1429
|
-
}
|
|
1430
|
-
if (error instanceof require_storage.ItdNetworkError || error instanceof require_storage.ItdTimeoutError) return safeToRepeat || retryNetworkWrite;
|
|
1431
|
-
if (error instanceof require_storage.ItdFileError) return error.retryable && (safeToRepeat || retryNetworkWrite);
|
|
1432
|
-
return false;
|
|
1433
|
-
}
|
|
1434
|
-
/** Экспоненциальная пауза со случайным разбросом. */
|
|
1435
|
-
function backoffDelay(attempt, options, random) {
|
|
1436
|
-
const exponential = options.baseDelay * 2 ** (attempt - 1);
|
|
1437
|
-
const capped = Math.min(exponential, options.maxDelay);
|
|
1438
|
-
const spread = capped * options.jitter * (random() * 2 - 1);
|
|
1439
|
-
return Math.max(0, Math.round(capped + spread));
|
|
1440
|
-
}
|
|
1441
|
-
/**
|
|
1442
|
-
* Собирает планировщик повторов для транспорта.
|
|
1443
|
-
*
|
|
1444
|
-
* Поведение при `Retry-After`: пауза, названная сервером, соблюдается точно — она
|
|
1445
|
-
* авторитетнее нашего расчёта. Но если сервер просит ждать дольше, чем `maxDelay`,
|
|
1446
|
-
* повтор **не выполняется вовсе**: молча спать десять минут внутри вызова библиотека
|
|
1447
|
-
* не должна, лучше отдать {@link ItdRateLimitError} и дать решить вызывающему коду.
|
|
1448
|
-
*
|
|
1449
|
-
* @param options настройки повторов после подстановки значений по умолчанию
|
|
1450
|
-
* @param random источник случайности; подменяется в тестах ради предсказуемости
|
|
1451
|
-
*
|
|
1452
|
-
* @example
|
|
1453
|
-
* ```ts
|
|
1454
|
-
* const scheduler = createRetryScheduler(config.retry);
|
|
1455
|
-
* const delay = scheduler(error, 1, 'GET'); // 500 мс ± 30%
|
|
1456
|
-
* ```
|
|
1457
|
-
*/
|
|
1458
|
-
function createRetryScheduler(options, random = Math.random) {
|
|
1459
|
-
return (error, attempt, method, retryNetworkWrite = false) => {
|
|
1460
|
-
if (attempt >= options.attempts) return void 0;
|
|
1461
|
-
if (options.shouldRetry) return options.shouldRetry(error, attempt) ? backoffDelay(attempt, options, random) : void 0;
|
|
1462
|
-
if (!isRetryable(error, method, options.retryWrites, retryNetworkWrite)) return void 0;
|
|
1463
|
-
if (error instanceof require_storage.ItdApiError && error.retryAfter !== void 0) return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
|
|
1464
|
-
return backoffDelay(attempt, options, random);
|
|
1465
|
-
};
|
|
1466
|
-
}
|
|
1467
|
-
//#endregion
|
|
1468
|
-
//#region src/core/middleware.ts
|
|
1469
|
-
/** Ожидание повтора, которое уважает отмену запроса. */
|
|
1470
|
-
function sleep(ms, signal) {
|
|
1471
|
-
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1472
|
-
if (signal.aborted) return Promise.reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
|
|
1473
|
-
return new Promise((resolve, reject) => {
|
|
1474
|
-
const timer = setTimeout(() => {
|
|
1475
|
-
signal.removeEventListener("abort", onAbort);
|
|
1476
|
-
resolve();
|
|
1477
|
-
}, ms);
|
|
1478
|
-
const onAbort = () => {
|
|
1479
|
-
clearTimeout(timer);
|
|
1480
|
-
reject(new require_storage.ItdAbortError("Запрос отменён во время ожидания повтора"));
|
|
1481
|
-
};
|
|
1482
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
1483
|
-
});
|
|
1484
|
-
}
|
|
1485
|
-
/**
|
|
1486
|
-
* Слой очереди: ограничение конкурентности и частоты.
|
|
1487
|
-
*
|
|
1488
|
-
* `skipQueue` пропускает запрос мимо очереди — так поступают служебные запросы, которые
|
|
1489
|
-
* порождены изнутри другого запроса и не могут ждать освободившегося слота.
|
|
1490
|
-
*
|
|
1491
|
-
* Очередь выбирается по запросу: у каждого сервиса платформы свой хост и свой лимит.
|
|
1492
|
-
*/
|
|
1493
|
-
function createQueueMiddleware(schedule) {
|
|
1494
|
-
return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
|
|
1495
|
-
}
|
|
1496
|
-
/**
|
|
1497
|
-
* Слой плагинов.
|
|
1498
|
-
*
|
|
1499
|
-
* Стоит снаружи повторов и внутри очереди: плагин должен увидеть запрос и ответ по одному
|
|
1500
|
-
* разу, независимо от числа попыток, — иначе, например, текст поста зашифруется дважды.
|
|
1501
|
-
*/
|
|
1502
|
-
function createPluginsMiddleware(plugins) {
|
|
1503
|
-
return (request, next) => plugins.run(request, next);
|
|
1504
|
-
}
|
|
1505
|
-
/**
|
|
1506
|
-
* Слой сервисов.
|
|
1507
|
-
*
|
|
1508
|
-
* Запросу с полем `service` подставляет хост сервиса, его заголовки и `skipAuth`, если
|
|
1509
|
-
* сервис объявлен публичным. Заданный у запроса `baseUrl` не трогает.
|
|
1510
|
-
*
|
|
1511
|
-
* Стоит снаружи повторов и авторизации, чтобы выставленный здесь `skipAuth` был ей виден.
|
|
1512
|
-
*/
|
|
1513
|
-
function createServicesMiddleware(registry) {
|
|
1514
|
-
return async (request, next) => {
|
|
1515
|
-
const service = request.service === void 0 ? void 0 : registry.require(request.service);
|
|
1516
|
-
let prepared = request;
|
|
1517
|
-
if (request.baseUrl !== void 0) {
|
|
1518
|
-
const baseUrl = normalizeBaseUrl(request.baseUrl);
|
|
1519
|
-
if (baseUrl !== request.baseUrl) prepared = {
|
|
1520
|
-
...prepared,
|
|
1521
|
-
baseUrl
|
|
1522
|
-
};
|
|
1523
|
-
if (!(service?.baseUrl === baseUrl ? service.auth !== false : registry.isPrimarySite(baseUrl)) && prepared.skipAuth === void 0) prepared = {
|
|
1524
|
-
...prepared,
|
|
1525
|
-
skipAuth: true
|
|
1526
|
-
};
|
|
1527
|
-
}
|
|
1528
|
-
if (!service) return next(prepared);
|
|
1529
|
-
if (prepared.baseUrl === void 0) prepared = {
|
|
1530
|
-
...prepared,
|
|
1531
|
-
baseUrl: service.baseUrl
|
|
1532
|
-
};
|
|
1533
|
-
if (service.headers) prepared = withLayerHeaders(prepared, service.headers);
|
|
1534
|
-
if (service.auth === false && prepared.skipAuth === void 0) prepared = {
|
|
1535
|
-
...prepared,
|
|
1536
|
-
skipAuth: true
|
|
1537
|
-
};
|
|
1538
|
-
return next(prepared);
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
async function applyAuth(request, deps) {
|
|
1542
|
-
if (request.skipAuth) return request;
|
|
1543
|
-
const headers = await deps.getAuthHeaders();
|
|
1544
|
-
return Object.keys(headers).length > 0 ? withLayerHeaders(request, headers) : request;
|
|
1545
|
-
}
|
|
1546
|
-
/**
|
|
1547
|
-
* Слой авторизации.
|
|
1548
|
-
*
|
|
1549
|
-
* Подставляет заголовок `Authorization` и обрабатывает `401`: обновляет токен и повторяет
|
|
1550
|
-
* запрос ровно один раз. Стоит внутри повторов, поэтому обычным попыткам он не виден —
|
|
1551
|
-
* они уже работают со свежим токеном.
|
|
1552
|
-
*/
|
|
1553
|
-
function createAuthMiddleware(deps) {
|
|
1554
|
-
return async (request, next) => {
|
|
1555
|
-
const authorized = await applyAuth(request, deps);
|
|
1556
|
-
try {
|
|
1557
|
-
return await next(authorized);
|
|
1558
|
-
} catch (error) {
|
|
1559
|
-
if (request.skipAuthRefresh || !deps.autoRefresh || !require_storage.isItdApiError(error) || error.status !== 401) throw error;
|
|
1560
|
-
if (!await deps.onUnauthorized()) throw error;
|
|
1561
|
-
return next(await applyAuth({
|
|
1562
|
-
...request,
|
|
1563
|
-
skipAuthRefresh: true
|
|
1564
|
-
}, deps));
|
|
1565
|
-
}
|
|
1566
|
-
};
|
|
1567
|
-
}
|
|
1568
|
-
/**
|
|
1569
|
-
* Выбирает планировщик отката для конкретного запроса.
|
|
1570
|
-
*
|
|
1571
|
-
* `retry` у запроса переопределяет глобальную настройку: `false` выключает повторы,
|
|
1572
|
-
* объект задаёт свои. Обработка `429` от этого не зависит — она общая.
|
|
1573
|
-
*/
|
|
1574
|
-
function resolveBackoff(retry, global) {
|
|
1575
|
-
if (retry === void 0) return global;
|
|
1576
|
-
if (retry === false) return void 0;
|
|
1577
|
-
const resolved = resolveRetry(retry);
|
|
1578
|
-
return resolved ? createRetryScheduler(resolved) : void 0;
|
|
1579
|
-
}
|
|
1580
|
-
/**
|
|
1581
|
-
* Слой повторов.
|
|
1582
|
-
*
|
|
1583
|
-
* Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
|
|
1584
|
-
* всей очереди; сетевые сбои и `5xx` — экспоненциальным откатом. Настройка `retry`
|
|
1585
|
-
* у отдельного запроса имеет приоритет над глобальной.
|
|
1586
|
-
*/
|
|
1587
|
-
function createRetryMiddleware(deps) {
|
|
1588
|
-
const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
|
|
1589
|
-
const nextDelay = (error, attempt, request, method, backoff) => {
|
|
1590
|
-
if (require_storage.isItdRateLimitError(error)) {
|
|
1591
|
-
const wait = error.retryAfter ?? deps.rateLimitDelays[attempt - 1];
|
|
1592
|
-
if (wait === void 0) return void 0;
|
|
1593
|
-
deps.pauseQueue?.(wait, request);
|
|
1594
|
-
deps.logger?.debug(`лимит частоты, попытка ${attempt + 1} через ${wait} мс`);
|
|
1595
|
-
return wait;
|
|
1596
|
-
}
|
|
1597
|
-
return backoff?.(error, attempt, method, request.retryNetworkWrite ?? false);
|
|
1598
|
-
};
|
|
1599
|
-
return async (request, next) => {
|
|
1600
|
-
const method = request.method.toUpperCase();
|
|
1601
|
-
const backoff = resolveBackoff(request.retry, globalScheduler);
|
|
1602
|
-
for (let attempt = 1;; attempt++) try {
|
|
1603
|
-
return await next({
|
|
1604
|
-
...request,
|
|
1605
|
-
attempt
|
|
1606
|
-
});
|
|
1607
|
-
} catch (error) {
|
|
1608
|
-
const delay = nextDelay(error, attempt, request, method, backoff);
|
|
1609
|
-
if (delay === void 0) throw error;
|
|
1610
|
-
await dispatchRequestHook(deps.hooks, "onRetry", {
|
|
1611
|
-
method,
|
|
1612
|
-
path: request.path,
|
|
1613
|
-
url: deps.buildUrl(request),
|
|
1614
|
-
headers: new Headers({
|
|
1615
|
-
...request.layerHeaders,
|
|
1616
|
-
...request.headers
|
|
1617
|
-
}),
|
|
1618
|
-
attempt,
|
|
1619
|
-
error,
|
|
1620
|
-
delay
|
|
1621
|
-
}, request);
|
|
1622
|
-
deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${attempt + 1} через ${delay} мс`);
|
|
1623
|
-
await sleep(delay, request.signal);
|
|
1624
|
-
}
|
|
1625
|
-
};
|
|
1626
|
-
}
|
|
1627
|
-
//#endregion
|
|
1628
1650
|
//#region src/core/rate-limit.ts
|
|
1629
1651
|
/** Ошибка отмены запроса, который ещё не дошёл до транспорта. */
|
|
1630
1652
|
function queueAbortError() {
|
|
@@ -1649,10 +1671,12 @@ var RequestQueue = class {
|
|
|
1649
1671
|
#active = 0;
|
|
1650
1672
|
/** Момент, раньше которого следующий запрос стартовать не должен. */
|
|
1651
1673
|
#nextSlot = 0;
|
|
1652
|
-
#
|
|
1653
|
-
|
|
1674
|
+
#clock;
|
|
1675
|
+
#cancelTimer;
|
|
1676
|
+
constructor(options, clock = systemClock) {
|
|
1654
1677
|
this.#concurrency = options.concurrency;
|
|
1655
1678
|
this.#minGap = options.rps ? 1e3 / options.rps : 0;
|
|
1679
|
+
this.#clock = clock;
|
|
1656
1680
|
}
|
|
1657
1681
|
/** Сколько задач выполняется прямо сейчас. */
|
|
1658
1682
|
get active() {
|
|
@@ -1704,9 +1728,9 @@ var RequestQueue = class {
|
|
|
1704
1728
|
* ошибкой `ItdAbortError`. Уже выполняющиеся задачи доводятся до конца.
|
|
1705
1729
|
*/
|
|
1706
1730
|
stop() {
|
|
1707
|
-
if (this.#
|
|
1708
|
-
|
|
1709
|
-
this.#
|
|
1731
|
+
if (this.#cancelTimer) {
|
|
1732
|
+
this.#cancelTimer();
|
|
1733
|
+
this.#cancelTimer = void 0;
|
|
1710
1734
|
}
|
|
1711
1735
|
this.#nextSlot = 0;
|
|
1712
1736
|
const pending = this.#waiting.splice(0, this.#waiting.length);
|
|
@@ -1720,23 +1744,23 @@ var RequestQueue = class {
|
|
|
1720
1744
|
*/
|
|
1721
1745
|
pause(ms) {
|
|
1722
1746
|
if (ms <= 0) return;
|
|
1723
|
-
this.#nextSlot = Math.max(this.#nextSlot,
|
|
1747
|
+
this.#nextSlot = Math.max(this.#nextSlot, this.#clock.now() + ms);
|
|
1724
1748
|
}
|
|
1725
1749
|
/** Запускает столько ожидающих задач, сколько позволяют ограничения. */
|
|
1726
1750
|
#drain() {
|
|
1727
1751
|
if (this.#waiting.length === 0) {
|
|
1728
|
-
if (this.#
|
|
1729
|
-
|
|
1730
|
-
this.#
|
|
1752
|
+
if (this.#cancelTimer) {
|
|
1753
|
+
this.#cancelTimer();
|
|
1754
|
+
this.#cancelTimer = void 0;
|
|
1731
1755
|
}
|
|
1732
1756
|
return;
|
|
1733
1757
|
}
|
|
1734
1758
|
if (this.#active >= this.#concurrency) return;
|
|
1735
|
-
if (this.#
|
|
1736
|
-
const now =
|
|
1759
|
+
if (this.#cancelTimer) return;
|
|
1760
|
+
const now = this.#clock.now();
|
|
1737
1761
|
if (this.#nextSlot > now) {
|
|
1738
|
-
this.#
|
|
1739
|
-
this.#
|
|
1762
|
+
this.#cancelTimer = this.#clock.schedule(() => {
|
|
1763
|
+
this.#cancelTimer = void 0;
|
|
1740
1764
|
this.#drain();
|
|
1741
1765
|
}, this.#nextSlot - now);
|
|
1742
1766
|
return;
|
|
@@ -1755,19 +1779,21 @@ var RequestQueue = class {
|
|
|
1755
1779
|
*/
|
|
1756
1780
|
var RequestQueuePool = class {
|
|
1757
1781
|
#options;
|
|
1782
|
+
#clock;
|
|
1758
1783
|
#main;
|
|
1759
1784
|
/** Очереди сервисов заводятся при первом запросе — обычно не нужна ни одна. */
|
|
1760
1785
|
#byService = /* @__PURE__ */ new Map();
|
|
1761
|
-
constructor(options) {
|
|
1786
|
+
constructor(options, clock = systemClock) {
|
|
1762
1787
|
this.#options = options;
|
|
1763
|
-
this.#
|
|
1788
|
+
this.#clock = clock;
|
|
1789
|
+
this.#main = new RequestQueue(options, clock);
|
|
1764
1790
|
}
|
|
1765
1791
|
/** Очередь хоста. */
|
|
1766
1792
|
for(service) {
|
|
1767
1793
|
if (service === void 0) return this.#main;
|
|
1768
1794
|
let queue = this.#byService.get(service);
|
|
1769
1795
|
if (!queue) {
|
|
1770
|
-
queue = new RequestQueue(this.#options);
|
|
1796
|
+
queue = new RequestQueue(this.#options, this.#clock);
|
|
1771
1797
|
this.#byService.set(service, queue);
|
|
1772
1798
|
}
|
|
1773
1799
|
return queue;
|
|
@@ -2215,7 +2241,7 @@ function createApiError(context) {
|
|
|
2215
2241
|
path: context.path,
|
|
2216
2242
|
raw: safeRawBody(context.body),
|
|
2217
2243
|
response: context.response,
|
|
2218
|
-
retryAfter: parseRetryAfter(context.headers?.get("retry-after"))
|
|
2244
|
+
retryAfter: parseRetryAfter(context.headers?.get("retry-after"), context.now)
|
|
2219
2245
|
};
|
|
2220
2246
|
if (parsed.code === "PHONE_VERIFICATION_REQUIRED") return new require_storage.ItdPhoneVerificationError({
|
|
2221
2247
|
...init,
|
|
@@ -2291,13 +2317,13 @@ function abortable(promise, signal) {
|
|
|
2291
2317
|
* Реализовано вручную, а не через `AbortSignal.any`: последний появился только в Node 20,
|
|
2292
2318
|
* а библиотека поддерживает Node 18.
|
|
2293
2319
|
*/
|
|
2294
|
-
function createAbortBundle(userSignal, timeout) {
|
|
2320
|
+
function createAbortBundle(userSignal, timeout, clock) {
|
|
2295
2321
|
const controller = new AbortController();
|
|
2296
2322
|
let timedOut = false;
|
|
2297
2323
|
const onUserAbort = () => controller.abort(userSignal?.reason);
|
|
2298
2324
|
if (userSignal) if (userSignal.aborted) controller.abort(userSignal.reason);
|
|
2299
2325
|
else userSignal.addEventListener("abort", onUserAbort, { once: true });
|
|
2300
|
-
const
|
|
2326
|
+
const cancelTimer = timeout > 0 ? clock.schedule(() => {
|
|
2301
2327
|
timedOut = true;
|
|
2302
2328
|
controller.abort();
|
|
2303
2329
|
}, timeout) : void 0;
|
|
@@ -2305,7 +2331,7 @@ function createAbortBundle(userSignal, timeout) {
|
|
|
2305
2331
|
signal: controller.signal,
|
|
2306
2332
|
timedOut: () => timedOut,
|
|
2307
2333
|
cleanup: () => {
|
|
2308
|
-
|
|
2334
|
+
cancelTimer?.();
|
|
2309
2335
|
userSignal?.removeEventListener("abort", onUserAbort);
|
|
2310
2336
|
}
|
|
2311
2337
|
};
|
|
@@ -2342,8 +2368,8 @@ var Transport = class {
|
|
|
2342
2368
|
const headers = await this.#buildHeaders(request, url);
|
|
2343
2369
|
const attempt = request.attempt ?? 1;
|
|
2344
2370
|
const timeout = request.timeout ?? this.#config.timeout;
|
|
2345
|
-
const abort = createAbortBundle(request.signal, timeout);
|
|
2346
|
-
const startedAt =
|
|
2371
|
+
const abort = createAbortBundle(request.signal, timeout, this.#config.clock);
|
|
2372
|
+
const startedAt = this.#config.clock.now();
|
|
2347
2373
|
let cleanupBody;
|
|
2348
2374
|
try {
|
|
2349
2375
|
let body;
|
|
@@ -2362,7 +2388,7 @@ var Transport = class {
|
|
|
2362
2388
|
};
|
|
2363
2389
|
await dispatchRequestHook(this.#config.hooks, "onError", {
|
|
2364
2390
|
...context,
|
|
2365
|
-
duration:
|
|
2391
|
+
duration: this.#config.clock.now() - startedAt,
|
|
2366
2392
|
error: failure
|
|
2367
2393
|
}, request);
|
|
2368
2394
|
throw failure;
|
|
@@ -2391,7 +2417,7 @@ var Transport = class {
|
|
|
2391
2417
|
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) init.duplex = "half";
|
|
2392
2418
|
response = await this.#config.fetch(url, init);
|
|
2393
2419
|
} catch (error) {
|
|
2394
|
-
const duration =
|
|
2420
|
+
const duration = this.#config.clock.now() - startedAt;
|
|
2395
2421
|
const failure = this.#toTransportError(error, abort, request, method, timeout);
|
|
2396
2422
|
await dispatchRequestHook(this.#config.hooks, "onError", {
|
|
2397
2423
|
...context,
|
|
@@ -2409,16 +2435,17 @@ var Transport = class {
|
|
|
2409
2435
|
if (response.ok) await dispatchRequestHook(this.#config.hooks, "onResponse", {
|
|
2410
2436
|
...context,
|
|
2411
2437
|
status: response.status,
|
|
2412
|
-
duration:
|
|
2438
|
+
duration: this.#config.clock.now() - startedAt,
|
|
2413
2439
|
response
|
|
2414
2440
|
}, request);
|
|
2415
2441
|
const payload = await this.#readBodyOrFail(response, context, request, method, abort, timeout);
|
|
2416
|
-
const duration =
|
|
2442
|
+
const duration = this.#config.clock.now() - startedAt;
|
|
2417
2443
|
if (!response.ok) {
|
|
2418
2444
|
const error = createApiError({
|
|
2419
2445
|
method,
|
|
2420
2446
|
path: request.path,
|
|
2421
2447
|
status: response.status,
|
|
2448
|
+
now: this.#config.clock.now(),
|
|
2422
2449
|
statusText: response.statusText,
|
|
2423
2450
|
headers: response.headers,
|
|
2424
2451
|
response,
|
|
@@ -2487,7 +2514,7 @@ var Transport = class {
|
|
|
2487
2514
|
}
|
|
2488
2515
|
/** Читает тело и преобразует ошибку чтения в транспортную ошибку библиотеки. */
|
|
2489
2516
|
async #readBodyOrFail(response, context, request, method, abort, timeout) {
|
|
2490
|
-
const startedAt =
|
|
2517
|
+
const startedAt = this.#config.clock.now();
|
|
2491
2518
|
try {
|
|
2492
2519
|
return await abortable(readBody(response), abort.signal);
|
|
2493
2520
|
} catch (error) {
|
|
@@ -2495,7 +2522,7 @@ var Transport = class {
|
|
|
2495
2522
|
const failure = this.#toTransportError(error, abort, request, method, timeout);
|
|
2496
2523
|
await dispatchRequestHook(this.#config.hooks, "onError", {
|
|
2497
2524
|
...context,
|
|
2498
|
-
duration:
|
|
2525
|
+
duration: this.#config.clock.now() - startedAt,
|
|
2499
2526
|
error: failure
|
|
2500
2527
|
}, request);
|
|
2501
2528
|
this.#config.logger?.warn(`× ${method} ${request.path}: не удалось прочитать тело ответа — ${failure.message}`);
|
|
@@ -2816,144 +2843,167 @@ const ItdErrorCode = Object.freeze({
|
|
|
2816
2843
|
WRITE_ACCESS_RESTRICTED: "WRITE_ACCESS_RESTRICTED"
|
|
2817
2844
|
});
|
|
2818
2845
|
//#endregion
|
|
2819
|
-
//#region src/
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
*/
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
return {
|
|
2870
|
-
id,
|
|
2871
|
-
username: asString(value.username) ?? "",
|
|
2872
|
-
displayName: asString(value.displayName) ?? "",
|
|
2873
|
-
avatar: asString(value.avatar) ?? "",
|
|
2874
|
-
...typeof value.isFollowing === "boolean" ? { isFollowing: value.isFollowing } : {},
|
|
2875
|
-
...typeof value.isFollowedBy === "boolean" ? { isFollowedBy: value.isFollowedBy } : {}
|
|
2876
|
-
};
|
|
2877
|
-
}
|
|
2878
|
-
/** Собирает участников: сервер присылает либо одного `actor`, либо массив `actors`. */
|
|
2879
|
-
function readActors(source) {
|
|
2880
|
-
if (Array.isArray(source.actors)) return source.actors.map(asActor).filter((actor) => actor !== void 0);
|
|
2881
|
-
const single = asActor(source.actor);
|
|
2882
|
-
return single ? [single] : [];
|
|
2883
|
-
}
|
|
2884
|
-
/**
|
|
2885
|
-
* Приводит уведомление к единой форме.
|
|
2886
|
-
*
|
|
2887
|
-
* Нужна потому, что REST-список и поток событий описывают одно и то же событие по-разному:
|
|
2888
|
-
* различаются имена типов (`like` против `post_reaction`), имена полей
|
|
2889
|
-
* (`targetId`/`entityId`, `read`/`isRead`, `preview`/`entityPreview`) и число участников
|
|
2890
|
-
* (`actor` против массива `actors`). После приведения объекты из обоих источников
|
|
2891
|
-
* можно складывать в один список.
|
|
2892
|
-
*
|
|
2893
|
-
* Исходные данные не теряются: имя типа с сервера остаётся в `rawType`,
|
|
2894
|
-
* весь объект целиком — в `raw`.
|
|
2895
|
-
*
|
|
2896
|
-
* @param input уведомление из REST-ответа либо полезная нагрузка события потока
|
|
2897
|
-
*
|
|
2898
|
-
* @example
|
|
2899
|
-
* ```ts
|
|
2900
|
-
* const fromRest = normalizeNotification(restItem);
|
|
2901
|
-
* const fromStream = normalizeNotification(event.payload);
|
|
2902
|
-
* // одинаковая форма — можно объединять
|
|
2903
|
-
* ```
|
|
2904
|
-
*/
|
|
2905
|
-
function normalizeNotification(input) {
|
|
2906
|
-
const source = isRecord(input) ? input : {};
|
|
2907
|
-
const payload = isRecord(source.payload) ? source.payload : source;
|
|
2908
|
-
const rawType = asString(payload.type) ?? asString(source.type) ?? "";
|
|
2909
|
-
const createdAt = asString(payload.createdAt) ?? asString(source.createdAt) ?? "";
|
|
2910
|
-
const readAt = asString(payload.readAt) ?? asString(source.readAt);
|
|
2911
|
-
const isRead = typeof payload.isRead === "boolean" ? payload.isRead : typeof payload.read === "boolean" ? payload.read : Boolean(readAt);
|
|
2912
|
-
const subjectId = asString(payload.subjectId);
|
|
2913
|
-
const targetId = asString(payload.targetId);
|
|
2914
|
-
const subjectIsComment = payload.subjectType === "comment";
|
|
2915
|
-
const clickUrl = asString(payload.clickUrl);
|
|
2916
|
-
return {
|
|
2917
|
-
id: asString(payload.id) ?? asString(source.id) ?? "",
|
|
2918
|
-
type: canonicalNotificationType(rawType),
|
|
2919
|
-
rawType,
|
|
2920
|
-
entityId: asString(payload.entityId) ?? (subjectIsComment ? subjectId ?? targetId : targetId) ?? null,
|
|
2921
|
-
parentEntityId: asString(payload.parentEntityId) ?? (subjectIsComment ? targetId ?? null : null),
|
|
2922
|
-
isRead,
|
|
2923
|
-
actors: readActors(payload),
|
|
2924
|
-
count: typeof payload.count === "number" && payload.count > 0 ? payload.count : 1,
|
|
2925
|
-
preview: asString(payload.entityPreview) ?? asString(payload.preview) ?? null,
|
|
2926
|
-
...clickUrl ? { clickUrl } : {},
|
|
2927
|
-
createdAt,
|
|
2928
|
-
updatedAt: asString(payload.updatedAt) ?? readAt ?? createdAt,
|
|
2929
|
-
raw: input
|
|
2930
|
-
};
|
|
2931
|
-
}
|
|
2932
|
-
/**
|
|
2933
|
-
* Разбирает событие `notification` из потока.
|
|
2934
|
-
*
|
|
2935
|
-
* Кроме самого уведомления событие несёт служебные поля уровня конверта: актуальный
|
|
2936
|
-
* счётчик непрочитанных и признак звука.
|
|
2937
|
-
*/
|
|
2938
|
-
function readNotificationEvent(data) {
|
|
2939
|
-
const source = isRecord(data) ? data : {};
|
|
2940
|
-
return {
|
|
2941
|
-
notification: normalizeNotification(data),
|
|
2942
|
-
unreadCount: typeof source.unreadCount === "number" ? source.unreadCount : void 0,
|
|
2943
|
-
sound: source.sound === true
|
|
2846
|
+
//#region src/realtime/middleware.ts
|
|
2847
|
+
const REALTIME_MIDDLEWARE_SNAPSHOT = Symbol("itd-api.realtime.middlewareSnapshot");
|
|
2848
|
+
/** @internal */
|
|
2849
|
+
function captureRealtimeMiddleware(middleware) {
|
|
2850
|
+
const capture = middleware[REALTIME_MIDDLEWARE_SNAPSHOT];
|
|
2851
|
+
if (!capture) return middleware;
|
|
2852
|
+
const snapshot = capture();
|
|
2853
|
+
if (typeof snapshot !== "function") throw new TypeError("Снимок промежуточного обработчика должен быть функцией");
|
|
2854
|
+
return snapshot;
|
|
2855
|
+
}
|
|
2856
|
+
/** @internal */
|
|
2857
|
+
function withRealtimeMiddlewareSnapshot(middleware, capture) {
|
|
2858
|
+
Object.defineProperty(middleware, REALTIME_MIDDLEWARE_SNAPSHOT, { value: capture });
|
|
2859
|
+
return middleware;
|
|
2860
|
+
}
|
|
2861
|
+
/** Выполняет промежуточные обработчики по порядку и запрещает повторный вызов `next()`. */
|
|
2862
|
+
async function runRealtimeMiddleware(middleware, context, terminal) {
|
|
2863
|
+
let lastIndex = -1;
|
|
2864
|
+
const dispatch = async (index) => {
|
|
2865
|
+
if (index <= lastIndex) throw new Error("next() в промежуточном обработчике вызван повторно");
|
|
2866
|
+
lastIndex = index;
|
|
2867
|
+
const current = middleware[index];
|
|
2868
|
+
if (!current) {
|
|
2869
|
+
await terminal();
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
let downstream;
|
|
2873
|
+
let duplicateCalls;
|
|
2874
|
+
let failure;
|
|
2875
|
+
const next = () => {
|
|
2876
|
+
if (!downstream) {
|
|
2877
|
+
downstream = dispatch(index + 1);
|
|
2878
|
+
return downstream;
|
|
2879
|
+
}
|
|
2880
|
+
const duplicate = Promise.reject(/* @__PURE__ */ new Error("next() в одном промежуточном обработчике вызван повторно"));
|
|
2881
|
+
duplicateCalls = Promise.all(duplicateCalls ? [duplicateCalls, duplicate] : [duplicate]).then(() => void 0);
|
|
2882
|
+
return duplicate;
|
|
2883
|
+
};
|
|
2884
|
+
try {
|
|
2885
|
+
await current(context, next);
|
|
2886
|
+
} catch (error) {
|
|
2887
|
+
failure = { error };
|
|
2888
|
+
}
|
|
2889
|
+
try {
|
|
2890
|
+
await downstream;
|
|
2891
|
+
await duplicateCalls;
|
|
2892
|
+
} catch (error) {
|
|
2893
|
+
failure ??= { error };
|
|
2894
|
+
}
|
|
2895
|
+
if (failure) throw failure.error;
|
|
2944
2896
|
};
|
|
2897
|
+
await dispatch(0);
|
|
2945
2898
|
}
|
|
2946
|
-
/**
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2899
|
+
/** Планирует нормализованные обновления и отслеживает незавершённые обработчики. */
|
|
2900
|
+
var RealtimeDispatcher = class {
|
|
2901
|
+
#options;
|
|
2902
|
+
#hooks;
|
|
2903
|
+
#middleware = [];
|
|
2904
|
+
#handlers = [];
|
|
2905
|
+
#queue = [];
|
|
2906
|
+
#activeKeys = /* @__PURE__ */ new Set();
|
|
2907
|
+
#drainWaiters = /* @__PURE__ */ new Set();
|
|
2908
|
+
#active = 0;
|
|
2909
|
+
constructor(options, hooks) {
|
|
2910
|
+
this.#options = options;
|
|
2911
|
+
this.#hooks = hooks;
|
|
2912
|
+
}
|
|
2913
|
+
use(middleware) {
|
|
2914
|
+
this.#middleware.push(middleware);
|
|
2915
|
+
return () => {
|
|
2916
|
+
const index = this.#middleware.indexOf(middleware);
|
|
2917
|
+
if (index >= 0) this.#middleware.splice(index, 1);
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
on(predicate, handler) {
|
|
2921
|
+
const registration = {
|
|
2922
|
+
predicate,
|
|
2923
|
+
handler
|
|
2924
|
+
};
|
|
2925
|
+
this.#handlers.push(registration);
|
|
2926
|
+
return () => {
|
|
2927
|
+
const index = this.#handlers.indexOf(registration);
|
|
2928
|
+
if (index >= 0) this.#handlers.splice(index, 1);
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
dispatch(context) {
|
|
2932
|
+
let keys;
|
|
2933
|
+
let middleware;
|
|
2934
|
+
try {
|
|
2935
|
+
keys = this.#keysFor(context);
|
|
2936
|
+
middleware = this.#middleware.map(captureRealtimeMiddleware);
|
|
2937
|
+
} catch (error) {
|
|
2938
|
+
this.#hooks.middlewareError(error, context);
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
this.#queue.push({
|
|
2942
|
+
context,
|
|
2943
|
+
middleware,
|
|
2944
|
+
handlers: [...this.#handlers],
|
|
2945
|
+
keys
|
|
2946
|
+
});
|
|
2947
|
+
this.#pump();
|
|
2948
|
+
}
|
|
2949
|
+
/** Отбрасывает обновления, обработка которых ещё не началась. */
|
|
2950
|
+
clearPending() {
|
|
2951
|
+
this.#queue.length = 0;
|
|
2952
|
+
this.#resolveDrain();
|
|
2953
|
+
}
|
|
2954
|
+
/** Ждёт завершения активных и поставленных в очередь обновлений. */
|
|
2955
|
+
drain() {
|
|
2956
|
+
if (this.#active === 0 && this.#queue.length === 0) return Promise.resolve();
|
|
2957
|
+
return new Promise((resolve) => this.#drainWaiters.add(resolve));
|
|
2958
|
+
}
|
|
2959
|
+
#keysFor(context) {
|
|
2960
|
+
const value = this.#options.sequentialize?.(context);
|
|
2961
|
+
if (value === void 0) return [];
|
|
2962
|
+
const values = Array.isArray(value) ? value : [value];
|
|
2963
|
+
for (const key of values) if (typeof key !== "string" && typeof key !== "number" && typeof key !== "symbol") throw new TypeError("sequentialize() должен возвращать PropertyKey или их список");
|
|
2964
|
+
return [...new Set(values)];
|
|
2965
|
+
}
|
|
2966
|
+
#pump() {
|
|
2967
|
+
while (this.#active < this.#options.concurrency) {
|
|
2968
|
+
const blockedKeys = new Set(this.#activeKeys);
|
|
2969
|
+
const index = this.#queue.findIndex(({ keys }) => {
|
|
2970
|
+
const runnable = keys.every((key) => !blockedKeys.has(key));
|
|
2971
|
+
if (!runnable) for (const key of keys) blockedKeys.add(key);
|
|
2972
|
+
return runnable;
|
|
2973
|
+
});
|
|
2974
|
+
if (index < 0) break;
|
|
2975
|
+
const [work] = this.#queue.splice(index, 1);
|
|
2976
|
+
if (!work) break;
|
|
2977
|
+
this.#active += 1;
|
|
2978
|
+
for (const key of work.keys) this.#activeKeys.add(key);
|
|
2979
|
+
this.#run(work);
|
|
2980
|
+
}
|
|
2981
|
+
this.#resolveDrain();
|
|
2982
|
+
}
|
|
2983
|
+
async #run(work) {
|
|
2984
|
+
try {
|
|
2985
|
+
await runRealtimeMiddleware(work.middleware, work.context, async () => {
|
|
2986
|
+
for (const { predicate, handler } of work.handlers) try {
|
|
2987
|
+
if (predicate(work.context)) await handler(work.context);
|
|
2988
|
+
} catch (error) {
|
|
2989
|
+
this.#hooks.handlerError(error, work.context);
|
|
2990
|
+
}
|
|
2991
|
+
this.#hooks.deliver(work.context);
|
|
2992
|
+
});
|
|
2993
|
+
} catch (error) {
|
|
2994
|
+
this.#hooks.middlewareError(error, work.context);
|
|
2995
|
+
} finally {
|
|
2996
|
+
this.#active -= 1;
|
|
2997
|
+
for (const key of work.keys) this.#activeKeys.delete(key);
|
|
2998
|
+
this.#pump();
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
#resolveDrain() {
|
|
3002
|
+
if (this.#active !== 0 || this.#queue.length !== 0) return;
|
|
3003
|
+
for (const resolve of this.#drainWaiters) resolve();
|
|
3004
|
+
this.#drainWaiters.clear();
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
2957
3007
|
//#endregion
|
|
2958
3008
|
//#region src/realtime/transport.ts
|
|
2959
3009
|
/** Ошибка, по которой видно, что сервер отверг авторизацию потока. */
|
|
@@ -2979,7 +3029,9 @@ var PollTransport = class {
|
|
|
2979
3029
|
name = "poll";
|
|
2980
3030
|
#interval;
|
|
2981
3031
|
#limit;
|
|
3032
|
+
#clock;
|
|
2982
3033
|
constructor(options = {}) {
|
|
3034
|
+
this.#clock = options.clock ?? systemClock;
|
|
2983
3035
|
this.#interval = options.interval ?? 15e3;
|
|
2984
3036
|
this.#limit = options.limit ?? 20;
|
|
2985
3037
|
}
|
|
@@ -3047,9 +3099,9 @@ var PollTransport = class {
|
|
|
3047
3099
|
#wait(signal) {
|
|
3048
3100
|
if (signal.aborted) return Promise.resolve();
|
|
3049
3101
|
return new Promise((resolve) => {
|
|
3050
|
-
const
|
|
3102
|
+
const cancel = this.#clock.schedule(finish, this.#interval);
|
|
3051
3103
|
function finish() {
|
|
3052
|
-
|
|
3104
|
+
cancel();
|
|
3053
3105
|
signal.removeEventListener("abort", finish);
|
|
3054
3106
|
resolve();
|
|
3055
3107
|
}
|
|
@@ -3286,9 +3338,11 @@ var SseTransport = class {
|
|
|
3286
3338
|
name = "sse";
|
|
3287
3339
|
#idleTimeout;
|
|
3288
3340
|
#handshakeTimeout;
|
|
3341
|
+
#clock;
|
|
3289
3342
|
/** Идентификатор последнего события — отправляется при переподключении. */
|
|
3290
3343
|
#lastEventId;
|
|
3291
3344
|
constructor(options = {}) {
|
|
3345
|
+
this.#clock = options.clock ?? systemClock;
|
|
3292
3346
|
this.#idleTimeout = options.idleTimeout ?? 9e4;
|
|
3293
3347
|
this.#handshakeTimeout = options.handshakeTimeout ?? 2e4;
|
|
3294
3348
|
}
|
|
@@ -3316,66 +3370,274 @@ var SseTransport = class {
|
|
|
3316
3370
|
context.signal.removeEventListener("abort", relayAbort);
|
|
3317
3371
|
}
|
|
3318
3372
|
}
|
|
3319
|
-
/** Выполняет запрос потока, обрывая его, если ответ не пришёл за отведённое время. */
|
|
3320
|
-
async #handshake(url, headers, context, controller) {
|
|
3321
|
-
let expired = false;
|
|
3322
|
-
const
|
|
3323
|
-
expired = true;
|
|
3324
|
-
controller.abort(/* @__PURE__ */ new Error("Истёк таймаут рукопожатия SSE"));
|
|
3325
|
-
}, this.#handshakeTimeout) : void 0;
|
|
3326
|
-
try {
|
|
3327
|
-
return await context.fetch(url, {
|
|
3328
|
-
method: "GET",
|
|
3329
|
-
headers,
|
|
3330
|
-
signal: controller.signal
|
|
3331
|
-
});
|
|
3332
|
-
} catch (error) {
|
|
3333
|
-
if (expired) throw new Error("Поток уведомлений не ответил: истёк таймаут рукопожатия");
|
|
3334
|
-
throw error;
|
|
3335
|
-
} finally {
|
|
3336
|
-
|
|
3337
|
-
}
|
|
3373
|
+
/** Выполняет запрос потока, обрывая его, если ответ не пришёл за отведённое время. */
|
|
3374
|
+
async #handshake(url, headers, context, controller) {
|
|
3375
|
+
let expired = false;
|
|
3376
|
+
const cancelTimer = this.#handshakeTimeout > 0 ? this.#clock.schedule(() => {
|
|
3377
|
+
expired = true;
|
|
3378
|
+
controller.abort(/* @__PURE__ */ new Error("Истёк таймаут рукопожатия SSE"));
|
|
3379
|
+
}, this.#handshakeTimeout) : void 0;
|
|
3380
|
+
try {
|
|
3381
|
+
return await context.fetch(url, {
|
|
3382
|
+
method: "GET",
|
|
3383
|
+
headers,
|
|
3384
|
+
signal: controller.signal
|
|
3385
|
+
});
|
|
3386
|
+
} catch (error) {
|
|
3387
|
+
if (expired) throw new Error("Поток уведомлений не ответил: истёк таймаут рукопожатия");
|
|
3388
|
+
throw error;
|
|
3389
|
+
} finally {
|
|
3390
|
+
cancelTimer?.();
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
async #read(body, context) {
|
|
3394
|
+
const reader = body.getReader();
|
|
3395
|
+
const decoder = new TextDecoder();
|
|
3396
|
+
const parser = createParser({ onEvent: (message) => {
|
|
3397
|
+
if (message.id) this.#lastEventId = message.id;
|
|
3398
|
+
let data;
|
|
3399
|
+
try {
|
|
3400
|
+
data = JSON.parse(message.data);
|
|
3401
|
+
} catch (error) {
|
|
3402
|
+
context.onParseError(error, message.data);
|
|
3403
|
+
return;
|
|
3404
|
+
}
|
|
3405
|
+
const name = message.event ?? (typeof data === "object" && data !== null && "type" in data ? String(data.type) : "message");
|
|
3406
|
+
context.onEvent({
|
|
3407
|
+
name,
|
|
3408
|
+
data
|
|
3409
|
+
});
|
|
3410
|
+
} });
|
|
3411
|
+
let cancelIdleTimer;
|
|
3412
|
+
const armIdleTimer = () => {
|
|
3413
|
+
if (this.#idleTimeout <= 0) return;
|
|
3414
|
+
cancelIdleTimer?.();
|
|
3415
|
+
cancelIdleTimer = this.#clock.schedule(() => {
|
|
3416
|
+
reader.cancel(/* @__PURE__ */ new Error("Поток молчит дольше допустимого")).catch(() => {});
|
|
3417
|
+
}, this.#idleTimeout);
|
|
3418
|
+
};
|
|
3419
|
+
armIdleTimer();
|
|
3420
|
+
try {
|
|
3421
|
+
for (;;) {
|
|
3422
|
+
const { done, value } = await reader.read();
|
|
3423
|
+
if (done) break;
|
|
3424
|
+
armIdleTimer();
|
|
3425
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
3426
|
+
}
|
|
3427
|
+
} finally {
|
|
3428
|
+
cancelIdleTimer?.();
|
|
3429
|
+
reader.releaseLock?.();
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
};
|
|
3433
|
+
//#endregion
|
|
3434
|
+
//#region src/notifications/type-map.ts
|
|
3435
|
+
/**
|
|
3436
|
+
* Соответствие коротких имён типов уведомлений развёрнутым.
|
|
3437
|
+
*
|
|
3438
|
+
* Сервер — и в списке, и в потоке событий — присылает короткие имена: `like`, `comment`,
|
|
3439
|
+
* `reply`, `repost`, `comment_like`. Развёрнутые (`post_reaction`, `post_comment`)
|
|
3440
|
+
* встречаются в оформлении интерфейса, поэтому библиотека приводит типы к ним:
|
|
3441
|
+
* они однозначно называют и объект, и действие.
|
|
3442
|
+
*
|
|
3443
|
+
* Пришедшее значение всегда остаётся в поле `rawType`.
|
|
3444
|
+
*/
|
|
3445
|
+
const NOTIFICATION_TYPE_ALIASES = Object.freeze({
|
|
3446
|
+
like: NotificationType.PostReaction,
|
|
3447
|
+
comment: NotificationType.PostComment,
|
|
3448
|
+
comment_like: NotificationType.CommentReaction,
|
|
3449
|
+
reply: NotificationType.CommentReply,
|
|
3450
|
+
repost: NotificationType.PostRepost,
|
|
3451
|
+
mention: NotificationType.PostMention
|
|
3452
|
+
});
|
|
3453
|
+
const KNOWN_TYPES = new Set(Object.values(NotificationType));
|
|
3454
|
+
/**
|
|
3455
|
+
* Приводит имя типа к каноническому.
|
|
3456
|
+
*
|
|
3457
|
+
* Неизвестное значение возвращается без изменений, чтобы не менять смысл нового типа
|
|
3458
|
+
* уведомления на другой.
|
|
3459
|
+
*
|
|
3460
|
+
* @example
|
|
3461
|
+
* ```ts
|
|
3462
|
+
* canonicalNotificationType('like'); // 'post_reaction'
|
|
3463
|
+
* canonicalNotificationType('post_reaction'); // 'post_reaction'
|
|
3464
|
+
* canonicalNotificationType('новое_событие'); // 'новое_событие'
|
|
3465
|
+
* ```
|
|
3466
|
+
*/
|
|
3467
|
+
function canonicalNotificationType(rawType) {
|
|
3468
|
+
return NOTIFICATION_TYPE_ALIASES[rawType] ?? rawType;
|
|
3469
|
+
}
|
|
3470
|
+
/**
|
|
3471
|
+
* Известен ли библиотеке этот тип уведомления.
|
|
3472
|
+
*
|
|
3473
|
+
* Полезно, чтобы решить, показывать ли уведомление, для которого нет своего оформления.
|
|
3474
|
+
*/
|
|
3475
|
+
function isKnownNotificationType(type) {
|
|
3476
|
+
return KNOWN_TYPES.has(canonicalNotificationType(type));
|
|
3477
|
+
}
|
|
3478
|
+
//#endregion
|
|
3479
|
+
//#region src/notifications/normalize.ts
|
|
3480
|
+
function asActor(value) {
|
|
3481
|
+
if (!isRecord(value)) return void 0;
|
|
3482
|
+
const id = asString(value.id);
|
|
3483
|
+
if (!id) return void 0;
|
|
3484
|
+
return {
|
|
3485
|
+
id,
|
|
3486
|
+
username: asString(value.username) ?? "",
|
|
3487
|
+
displayName: asString(value.displayName) ?? "",
|
|
3488
|
+
avatar: asString(value.avatar) ?? "",
|
|
3489
|
+
...typeof value.isFollowing === "boolean" ? { isFollowing: value.isFollowing } : {},
|
|
3490
|
+
...typeof value.isFollowedBy === "boolean" ? { isFollowedBy: value.isFollowedBy } : {}
|
|
3491
|
+
};
|
|
3492
|
+
}
|
|
3493
|
+
/** Собирает участников: сервер присылает либо одного `actor`, либо массив `actors`. */
|
|
3494
|
+
function readActors(source) {
|
|
3495
|
+
if (Array.isArray(source.actors)) return source.actors.map(asActor).filter((actor) => actor !== void 0);
|
|
3496
|
+
const single = asActor(source.actor);
|
|
3497
|
+
return single ? [single] : [];
|
|
3498
|
+
}
|
|
3499
|
+
/**
|
|
3500
|
+
* Приводит уведомление к единой форме.
|
|
3501
|
+
*
|
|
3502
|
+
* Нужна потому, что REST-список и поток событий описывают одно и то же событие по-разному:
|
|
3503
|
+
* различаются имена типов (`like` против `post_reaction`), имена полей
|
|
3504
|
+
* (`targetId`/`entityId`, `read`/`isRead`, `preview`/`entityPreview`) и число участников
|
|
3505
|
+
* (`actor` против массива `actors`). После приведения объекты из обоих источников
|
|
3506
|
+
* можно складывать в один список.
|
|
3507
|
+
*
|
|
3508
|
+
* Исходные данные не теряются: имя типа с сервера остаётся в `rawType`,
|
|
3509
|
+
* весь объект целиком — в `raw`.
|
|
3510
|
+
*
|
|
3511
|
+
* @param input уведомление из REST-ответа либо полезная нагрузка события потока
|
|
3512
|
+
*
|
|
3513
|
+
* @example
|
|
3514
|
+
* ```ts
|
|
3515
|
+
* const fromRest = normalizeNotification(restItem);
|
|
3516
|
+
* const fromStream = normalizeNotification(event.payload);
|
|
3517
|
+
* // одинаковая форма — можно объединять
|
|
3518
|
+
* ```
|
|
3519
|
+
*/
|
|
3520
|
+
function normalizeNotification(input) {
|
|
3521
|
+
const source = isRecord(input) ? input : {};
|
|
3522
|
+
const payload = isRecord(source.payload) ? source.payload : source;
|
|
3523
|
+
const rawType = asString(payload.type) ?? asString(source.type) ?? "";
|
|
3524
|
+
const createdAt = asString(payload.createdAt) ?? asString(source.createdAt) ?? "";
|
|
3525
|
+
const readAt = asString(payload.readAt) ?? asString(source.readAt);
|
|
3526
|
+
const isRead = typeof payload.isRead === "boolean" ? payload.isRead : typeof payload.read === "boolean" ? payload.read : Boolean(readAt);
|
|
3527
|
+
const subjectId = asString(payload.subjectId);
|
|
3528
|
+
const targetId = asString(payload.targetId);
|
|
3529
|
+
const subjectIsComment = payload.subjectType === "comment";
|
|
3530
|
+
const clickUrl = asString(payload.clickUrl);
|
|
3531
|
+
return {
|
|
3532
|
+
id: asString(payload.id) ?? asString(source.id) ?? "",
|
|
3533
|
+
type: canonicalNotificationType(rawType),
|
|
3534
|
+
rawType,
|
|
3535
|
+
entityId: asString(payload.entityId) ?? (subjectIsComment ? subjectId ?? targetId : targetId) ?? null,
|
|
3536
|
+
parentEntityId: asString(payload.parentEntityId) ?? (subjectIsComment ? targetId ?? null : null),
|
|
3537
|
+
isRead,
|
|
3538
|
+
actors: readActors(payload),
|
|
3539
|
+
count: typeof payload.count === "number" && payload.count > 0 ? payload.count : 1,
|
|
3540
|
+
preview: asString(payload.entityPreview) ?? asString(payload.preview) ?? null,
|
|
3541
|
+
...clickUrl ? { clickUrl } : {},
|
|
3542
|
+
createdAt,
|
|
3543
|
+
updatedAt: asString(payload.updatedAt) ?? readAt ?? createdAt,
|
|
3544
|
+
raw: input
|
|
3545
|
+
};
|
|
3546
|
+
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Разбирает событие `notification` из потока.
|
|
3549
|
+
*
|
|
3550
|
+
* Кроме самого уведомления событие несёт служебные поля уровня конверта: актуальный
|
|
3551
|
+
* счётчик непрочитанных и признак звука.
|
|
3552
|
+
*/
|
|
3553
|
+
function readNotificationEvent(data) {
|
|
3554
|
+
const source = isRecord(data) ? data : {};
|
|
3555
|
+
return {
|
|
3556
|
+
notification: normalizeNotification(data),
|
|
3557
|
+
unreadCount: typeof source.unreadCount === "number" ? source.unreadCount : void 0,
|
|
3558
|
+
sound: source.sound === true
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
/**
|
|
3562
|
+
* Разбирает событие `unread_count` из потока.
|
|
3563
|
+
*
|
|
3564
|
+
* Возвращает `undefined`, если сервер прислал событие без вложенного `payload`.
|
|
3565
|
+
*/
|
|
3566
|
+
function readUnreadCountEvent(data) {
|
|
3567
|
+
if (!isRecord(data)) return void 0;
|
|
3568
|
+
const payload = isRecord(data.payload) ? data.payload : void 0;
|
|
3569
|
+
if (!payload) return void 0;
|
|
3570
|
+
return typeof payload.count === "number" ? payload.count : void 0;
|
|
3571
|
+
}
|
|
3572
|
+
//#endregion
|
|
3573
|
+
//#region src/realtime/updates.ts
|
|
3574
|
+
/** Типы нормализованных обновлений потока. */
|
|
3575
|
+
const RealtimeUpdateType = Object.freeze({
|
|
3576
|
+
Notification: "notification",
|
|
3577
|
+
UnreadCount: "unreadCount",
|
|
3578
|
+
Unknown: "unknown"
|
|
3579
|
+
});
|
|
3580
|
+
/** Источники нормализованных обновлений потока. */
|
|
3581
|
+
const RealtimeUpdateOrigin = Object.freeze({
|
|
3582
|
+
Stream: "stream",
|
|
3583
|
+
Sync: "sync"
|
|
3584
|
+
});
|
|
3585
|
+
/** Проверяет форму фильтра уведомлений. */
|
|
3586
|
+
function validateNotificationSelector(selector) {
|
|
3587
|
+
if (typeof selector === "string") {
|
|
3588
|
+
if (selector.length === 0) throw new require_storage.ItdConfigError("Тип уведомления не должен быть пустым");
|
|
3589
|
+
return;
|
|
3590
|
+
}
|
|
3591
|
+
if (Array.isArray(selector)) {
|
|
3592
|
+
if (selector.length === 0 || selector.some((type) => typeof type !== "string" || !type)) throw new require_storage.ItdConfigError("Список типов уведомлений должен содержать непустые строки");
|
|
3593
|
+
return;
|
|
3338
3594
|
}
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
3361
|
-
idleTimer = setTimeout(() => {
|
|
3362
|
-
reader.cancel(/* @__PURE__ */ new Error("Поток молчит дольше допустимого")).catch(() => {});
|
|
3363
|
-
}, this.#idleTimeout);
|
|
3595
|
+
if (typeof selector !== "object" || selector === null) throw new require_storage.ItdConfigError("Фильтр уведомлений должен быть строкой, списком или объектом");
|
|
3596
|
+
const filter = selector;
|
|
3597
|
+
if (filter.type !== void 0) validateNotificationSelector(filter.type);
|
|
3598
|
+
if (filter.actorId !== void 0 && typeof filter.actorId !== "string") throw new require_storage.ItdConfigError("Фильтр уведомлений: actorId должен быть строкой");
|
|
3599
|
+
for (const field of ["entityId", "parentEntityId"]) {
|
|
3600
|
+
const value = filter[field];
|
|
3601
|
+
if (value !== void 0 && value !== null && typeof value !== "string") throw new require_storage.ItdConfigError(`Фильтр уведомлений: ${field} должен быть строкой или null`);
|
|
3602
|
+
}
|
|
3603
|
+
if (filter.predicate !== void 0 && typeof filter.predicate !== "function") throw new require_storage.ItdConfigError("Фильтр уведомлений: predicate должен быть функцией");
|
|
3604
|
+
}
|
|
3605
|
+
/** Преобразует транспортный кадр в одно логическое обновление. */
|
|
3606
|
+
function readRealtimeUpdate(event) {
|
|
3607
|
+
if (event.name === "notification") return {
|
|
3608
|
+
type: RealtimeUpdateType.Notification,
|
|
3609
|
+
data: readNotificationEvent(event.data)
|
|
3610
|
+
};
|
|
3611
|
+
if (event.name === "unread_count") {
|
|
3612
|
+
const count = readUnreadCountEvent(event.data);
|
|
3613
|
+
return count === void 0 ? void 0 : {
|
|
3614
|
+
type: RealtimeUpdateType.UnreadCount,
|
|
3615
|
+
data: count
|
|
3364
3616
|
};
|
|
3365
|
-
armIdleTimer();
|
|
3366
|
-
try {
|
|
3367
|
-
for (;;) {
|
|
3368
|
-
const { done, value } = await reader.read();
|
|
3369
|
-
if (done) break;
|
|
3370
|
-
armIdleTimer();
|
|
3371
|
-
parser.feed(decoder.decode(value, { stream: true }));
|
|
3372
|
-
}
|
|
3373
|
-
} finally {
|
|
3374
|
-
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
3375
|
-
reader.releaseLock?.();
|
|
3376
|
-
}
|
|
3377
3617
|
}
|
|
3378
|
-
|
|
3618
|
+
return {
|
|
3619
|
+
type: RealtimeUpdateType.Unknown,
|
|
3620
|
+
name: event.name,
|
|
3621
|
+
data: event.data
|
|
3622
|
+
};
|
|
3623
|
+
}
|
|
3624
|
+
/** Проверяет объектный или краткий фильтр уведомления. */
|
|
3625
|
+
function matchesNotification(context, selector) {
|
|
3626
|
+
const notification = context.update.data.notification;
|
|
3627
|
+
if (typeof selector === "string") return notification.type === selector;
|
|
3628
|
+
if (Array.isArray(selector)) return selector.includes(notification.type);
|
|
3629
|
+
const filter = selector;
|
|
3630
|
+
const types = filter.type === void 0 ? void 0 : [filter.type].flat();
|
|
3631
|
+
if (types && !types.includes(notification.type)) return false;
|
|
3632
|
+
if (filter.actorId !== void 0 && !notification.actors.some(({ id }) => id === filter.actorId)) return false;
|
|
3633
|
+
if (filter.entityId !== void 0 && notification.entityId !== filter.entityId) return false;
|
|
3634
|
+
if (filter.parentEntityId !== void 0 && notification.parentEntityId !== filter.parentEntityId) return false;
|
|
3635
|
+
return filter.predicate?.(context) ?? true;
|
|
3636
|
+
}
|
|
3637
|
+
/** Сужает произвольный контекст потока до контекста уведомления. */
|
|
3638
|
+
function isNotificationContext(context) {
|
|
3639
|
+
return context.update.type === RealtimeUpdateType.Notification;
|
|
3640
|
+
}
|
|
3379
3641
|
//#endregion
|
|
3380
3642
|
//#region src/realtime/stream.ts
|
|
3381
3643
|
/** Способ получения событий. */
|
|
@@ -3398,6 +3660,8 @@ function validateRealtimeOptions(options) {
|
|
|
3398
3660
|
if (!Number.isFinite(value) || value < min) throw new require_storage.ItdConfigError(`realtime.${name} должен быть числом не меньше ${min}, получено: ${value}`);
|
|
3399
3661
|
};
|
|
3400
3662
|
positiveInteger(options.maxAttempts, "maxAttempts");
|
|
3663
|
+
positiveInteger(options.concurrency, "concurrency");
|
|
3664
|
+
if (options.concurrency === 0) throw new require_storage.ItdConfigError("realtime.concurrency должен быть больше нуля");
|
|
3401
3665
|
duration(options.pollInterval, "pollInterval", 1);
|
|
3402
3666
|
duration(options.idleTimeout, "idleTimeout", 0);
|
|
3403
3667
|
duration(options.handshakeTimeout, "handshakeTimeout", 0);
|
|
@@ -3406,6 +3670,7 @@ function validateRealtimeOptions(options) {
|
|
|
3406
3670
|
if (!Array.isArray(options.backoff) || options.backoff.length === 0) throw new require_storage.ItdConfigError("realtime.backoff должен быть непустым списком пауз");
|
|
3407
3671
|
for (const delay of options.backoff) duration(delay, "backoff", 0);
|
|
3408
3672
|
}
|
|
3673
|
+
if (options.sequentialize !== void 0 && typeof options.sequentialize !== "function") throw new require_storage.ItdConfigError("realtime.sequentialize должен быть функцией");
|
|
3409
3674
|
}
|
|
3410
3675
|
/**
|
|
3411
3676
|
* Поток уведомлений в реальном времени.
|
|
@@ -3415,22 +3680,26 @@ function validateRealtimeOptions(options) {
|
|
|
3415
3680
|
*
|
|
3416
3681
|
* @example
|
|
3417
3682
|
* ```ts
|
|
3683
|
+
* import { NotificationType } from 'itd-api';
|
|
3684
|
+
*
|
|
3418
3685
|
* const stream = itd.realtime();
|
|
3419
3686
|
*
|
|
3420
|
-
* stream.
|
|
3421
|
-
*
|
|
3687
|
+
* stream.onNotification(NotificationType.PostComment, async ({ update }) => {
|
|
3688
|
+
* await saveCommentNotification(update.data.notification);
|
|
3422
3689
|
* });
|
|
3423
3690
|
* stream.on('status', (status) => console.log('соединение:', status));
|
|
3424
3691
|
*
|
|
3425
3692
|
* await stream.connect();
|
|
3426
3693
|
* // …позже
|
|
3427
3694
|
* stream.disconnect();
|
|
3695
|
+
* await stream.drain();
|
|
3428
3696
|
* ```
|
|
3429
3697
|
*/
|
|
3430
3698
|
var ItdRealtime = class {
|
|
3431
3699
|
#deps;
|
|
3432
3700
|
#options;
|
|
3433
3701
|
#emitter;
|
|
3702
|
+
#dispatcher;
|
|
3434
3703
|
#transport;
|
|
3435
3704
|
#maxAttempts;
|
|
3436
3705
|
#controller;
|
|
@@ -3445,7 +3714,7 @@ var ItdRealtime = class {
|
|
|
3445
3714
|
#wanted = false;
|
|
3446
3715
|
#status = RealtimeStatus.Disconnected;
|
|
3447
3716
|
#attempt = 0;
|
|
3448
|
-
#
|
|
3717
|
+
#cancelTimer;
|
|
3449
3718
|
#detachEnvironment;
|
|
3450
3719
|
constructor(deps, options = {}) {
|
|
3451
3720
|
validateRealtimeOptions(options);
|
|
@@ -3458,6 +3727,14 @@ var ItdRealtime = class {
|
|
|
3458
3727
|
if (deps.logger) deps.logger.error(message, error);
|
|
3459
3728
|
else console.error(`[itd-api] ${message}`, error);
|
|
3460
3729
|
});
|
|
3730
|
+
this.#dispatcher = new RealtimeDispatcher({
|
|
3731
|
+
concurrency: options.concurrency ?? 1,
|
|
3732
|
+
...options.sequentialize ? { sequentialize: options.sequentialize } : {}
|
|
3733
|
+
}, {
|
|
3734
|
+
deliver: (context) => this.#deliver(context.update),
|
|
3735
|
+
middlewareError: (error, context) => this.#reportDispatchError("middlewareError", error, context),
|
|
3736
|
+
handlerError: (error, context) => this.#reportDispatchError("handlerError", error, context)
|
|
3737
|
+
});
|
|
3461
3738
|
}
|
|
3462
3739
|
/** Текущее состояние соединения. */
|
|
3463
3740
|
get status() {
|
|
@@ -3488,6 +3765,40 @@ var ItdRealtime = class {
|
|
|
3488
3765
|
return this.#emitter.once(event, listener);
|
|
3489
3766
|
}
|
|
3490
3767
|
/**
|
|
3768
|
+
* Добавляет промежуточный обработчик нормализованных обновлений.
|
|
3769
|
+
*
|
|
3770
|
+
* Обработчики выполняются в порядке регистрации. Если `next()` не вызван, обновление не
|
|
3771
|
+
* передаётся дальше по цепочке, асинхронным обработчикам и слушателям событий.
|
|
3772
|
+
*
|
|
3773
|
+
* @returns функция удаления обработчика
|
|
3774
|
+
*/
|
|
3775
|
+
use(middleware) {
|
|
3776
|
+
if (typeof middleware !== "function") throw new require_storage.ItdConfigError("realtime.use() принимает функцию обработки");
|
|
3777
|
+
return this.#dispatcher.use(middleware);
|
|
3778
|
+
}
|
|
3779
|
+
onUpdate(selectorOrHandler, selectedHandler) {
|
|
3780
|
+
const selectAll = selectedHandler === void 0;
|
|
3781
|
+
const handler = selectAll ? selectorOrHandler : selectedHandler;
|
|
3782
|
+
if (typeof handler !== "function") throw new require_storage.ItdConfigError("realtime.onUpdate() принимает функцию обработчика");
|
|
3783
|
+
if (!selectAll && typeof selectorOrHandler !== "function" && !Object.values(RealtimeUpdateType).includes(selectorOrHandler)) throw new require_storage.ItdConfigError(`Неизвестный тип обновления потока: ${String(selectorOrHandler)}`);
|
|
3784
|
+
let predicate;
|
|
3785
|
+
if (selectAll) predicate = () => true;
|
|
3786
|
+
else {
|
|
3787
|
+
const selector = selectorOrHandler;
|
|
3788
|
+
predicate = typeof selector === "function" ? selector : (context) => context.update.type === selector;
|
|
3789
|
+
}
|
|
3790
|
+
return this.#dispatcher.on(predicate, handler);
|
|
3791
|
+
}
|
|
3792
|
+
onNotification(selector, handler) {
|
|
3793
|
+
if (typeof handler !== "function") throw new require_storage.ItdConfigError("realtime.onNotification() принимает функцию обработчика");
|
|
3794
|
+
if (typeof selector !== "function") validateNotificationSelector(selector);
|
|
3795
|
+
const predicate = (context) => {
|
|
3796
|
+
if (!isNotificationContext(context)) return false;
|
|
3797
|
+
return typeof selector === "function" ? selector(context) : matchesNotification(context, selector);
|
|
3798
|
+
};
|
|
3799
|
+
return this.#dispatcher.on(predicate, handler);
|
|
3800
|
+
}
|
|
3801
|
+
/**
|
|
3491
3802
|
* Поднимает соединение.
|
|
3492
3803
|
*
|
|
3493
3804
|
* Повторный вызов при уже живом соединении ничего не делает — это защита от двойного
|
|
@@ -3498,9 +3809,14 @@ var ItdRealtime = class {
|
|
|
3498
3809
|
async connect() {
|
|
3499
3810
|
if (this.#wanted) return;
|
|
3500
3811
|
this.#wanted = true;
|
|
3812
|
+
this.#deps.onConnect?.();
|
|
3501
3813
|
this.#attachEnvironmentListeners();
|
|
3502
3814
|
if (this.#options.syncCount !== false) try {
|
|
3503
|
-
|
|
3815
|
+
const count = await this.#deps.fetchUnreadCount();
|
|
3816
|
+
if (this.#wanted) this.#dispatch({
|
|
3817
|
+
type: RealtimeUpdateType.UnreadCount,
|
|
3818
|
+
data: count
|
|
3819
|
+
}, void 0, RealtimeUpdateOrigin.Sync);
|
|
3504
3820
|
} catch (error) {
|
|
3505
3821
|
this.#deps.logger?.debug("не удалось получить число непрочитанных", error);
|
|
3506
3822
|
}
|
|
@@ -3509,27 +3825,36 @@ var ItdRealtime = class {
|
|
|
3509
3825
|
/** Закрывает соединение и отменяет запланированные попытки. */
|
|
3510
3826
|
disconnect() {
|
|
3511
3827
|
this.#wanted = false;
|
|
3512
|
-
if (this.#
|
|
3513
|
-
|
|
3514
|
-
this.#
|
|
3828
|
+
if (this.#cancelTimer) {
|
|
3829
|
+
this.#cancelTimer();
|
|
3830
|
+
this.#cancelTimer = void 0;
|
|
3515
3831
|
}
|
|
3516
3832
|
this.#detachEnvironment?.();
|
|
3517
3833
|
this.#detachEnvironment = void 0;
|
|
3518
3834
|
this.#controller?.abort();
|
|
3519
3835
|
this.#controller = void 0;
|
|
3520
3836
|
this.#attempt = 0;
|
|
3837
|
+
this.#dispatcher.clearPending();
|
|
3521
3838
|
this.#setStatus(RealtimeStatus.Disconnected);
|
|
3522
3839
|
this.#deps.onClose?.();
|
|
3523
3840
|
}
|
|
3524
|
-
/**
|
|
3841
|
+
/** Ждёт завершения всех принятых обновлений. */
|
|
3842
|
+
drain() {
|
|
3843
|
+
return this.#dispatcher.drain();
|
|
3844
|
+
}
|
|
3845
|
+
/** Снимает подписки `on()` и `once()`. Остальные обработчики остаются. */
|
|
3525
3846
|
removeAllListeners() {
|
|
3526
3847
|
this.#emitter.removeAllListeners();
|
|
3527
3848
|
}
|
|
3528
3849
|
#createTransport() {
|
|
3529
3850
|
const kind = this.#options.transport ?? RealtimeTransportKind.Auto;
|
|
3530
3851
|
if (typeof kind === "object") return kind;
|
|
3531
|
-
if (kind === RealtimeTransportKind.Poll || kind === RealtimeTransportKind.Auto && !require_runtime.supportsStreamingBody()) return new PollTransport({
|
|
3852
|
+
if (kind === RealtimeTransportKind.Poll || kind === RealtimeTransportKind.Auto && !require_runtime.supportsStreamingBody()) return new PollTransport({
|
|
3853
|
+
clock: this.#deps.clock ?? systemClock,
|
|
3854
|
+
...this.#options.pollInterval !== void 0 ? { interval: this.#options.pollInterval } : {}
|
|
3855
|
+
});
|
|
3532
3856
|
return new SseTransport({
|
|
3857
|
+
clock: this.#deps.clock ?? systemClock,
|
|
3533
3858
|
...this.#options.idleTimeout !== void 0 ? { idleTimeout: this.#options.idleTimeout } : {},
|
|
3534
3859
|
...this.#options.handshakeTimeout !== void 0 ? { handshakeTimeout: this.#options.handshakeTimeout } : {}
|
|
3535
3860
|
});
|
|
@@ -3551,7 +3876,7 @@ var ItdRealtime = class {
|
|
|
3551
3876
|
this.#attempt = 0;
|
|
3552
3877
|
this.#setStatus(RealtimeStatus.Connected);
|
|
3553
3878
|
},
|
|
3554
|
-
onEvent: (event) => this.#handleEvent(event
|
|
3879
|
+
onEvent: (event) => this.#handleEvent(event),
|
|
3555
3880
|
onParseError: (error, raw) => this.#emitter.emit("parseError", {
|
|
3556
3881
|
error,
|
|
3557
3882
|
raw
|
|
@@ -3563,25 +3888,48 @@ var ItdRealtime = class {
|
|
|
3563
3888
|
this.#handleFailure(error);
|
|
3564
3889
|
});
|
|
3565
3890
|
}
|
|
3566
|
-
#handleEvent(
|
|
3567
|
-
this.#
|
|
3568
|
-
|
|
3569
|
-
|
|
3891
|
+
#handleEvent(event) {
|
|
3892
|
+
if (!this.#wanted) return;
|
|
3893
|
+
this.#emitter.emit("message", event);
|
|
3894
|
+
if (event.name === "connected") {
|
|
3895
|
+
this.#emitter.emit("ready", { userId: pickString(event.data, "userId") });
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
const update = readRealtimeUpdate(event);
|
|
3899
|
+
if (update) this.#dispatch(update, event, RealtimeUpdateOrigin.Stream);
|
|
3900
|
+
}
|
|
3901
|
+
#dispatch(update, raw, origin) {
|
|
3902
|
+
this.#dispatcher.dispatch({
|
|
3903
|
+
update,
|
|
3904
|
+
stream: this,
|
|
3905
|
+
raw,
|
|
3906
|
+
origin
|
|
3570
3907
|
});
|
|
3571
|
-
|
|
3572
|
-
|
|
3908
|
+
}
|
|
3909
|
+
#deliver(update) {
|
|
3910
|
+
if (update.type === RealtimeUpdateType.Notification) {
|
|
3911
|
+
this.#emitter.emit("notification", update.data);
|
|
3912
|
+
if (update.data.unreadCount !== void 0) this.#emitter.emit("unreadCount", update.data.unreadCount);
|
|
3573
3913
|
return;
|
|
3574
3914
|
}
|
|
3575
|
-
if (
|
|
3576
|
-
|
|
3577
|
-
this.#emitter.emit("notification", event);
|
|
3578
|
-
if (event.unreadCount !== void 0) this.#emitter.emit("unreadCount", event.unreadCount);
|
|
3915
|
+
if (update.type === RealtimeUpdateType.UnreadCount) {
|
|
3916
|
+
this.#emitter.emit("unreadCount", update.data);
|
|
3579
3917
|
return;
|
|
3580
3918
|
}
|
|
3581
|
-
if (
|
|
3582
|
-
|
|
3583
|
-
|
|
3919
|
+
if (update.type === RealtimeUpdateType.Unknown) return;
|
|
3920
|
+
assertNeverUpdate(update);
|
|
3921
|
+
}
|
|
3922
|
+
#reportDispatchError(event, error, context) {
|
|
3923
|
+
if (this.#emitter.listenerCount(event) > 0) {
|
|
3924
|
+
this.#emitter.emit(event, {
|
|
3925
|
+
error,
|
|
3926
|
+
context
|
|
3927
|
+
});
|
|
3928
|
+
return;
|
|
3584
3929
|
}
|
|
3930
|
+
const message = event === "middlewareError" ? "Ошибка в промежуточном обработчике потока" : "Ошибка в обработчике обновления потока";
|
|
3931
|
+
if (this.#deps.logger) this.#deps.logger.error(message, error);
|
|
3932
|
+
else console.error(`[itd-api] ${message}`, error);
|
|
3585
3933
|
}
|
|
3586
3934
|
#handleFailure(error) {
|
|
3587
3935
|
this.#controller = void 0;
|
|
@@ -3619,8 +3967,8 @@ var ItdRealtime = class {
|
|
|
3619
3967
|
attempt: this.#attempt,
|
|
3620
3968
|
delay
|
|
3621
3969
|
});
|
|
3622
|
-
this.#
|
|
3623
|
-
this.#
|
|
3970
|
+
this.#cancelTimer = (this.#deps.clock ?? systemClock).schedule(() => {
|
|
3971
|
+
this.#cancelTimer = void 0;
|
|
3624
3972
|
this.#run();
|
|
3625
3973
|
}, delay);
|
|
3626
3974
|
}
|
|
@@ -3647,7 +3995,7 @@ var ItdRealtime = class {
|
|
|
3647
3995
|
const target = globalThis;
|
|
3648
3996
|
if (typeof target.addEventListener !== "function") return;
|
|
3649
3997
|
const wake = () => {
|
|
3650
|
-
if (this.#controller || this.#
|
|
3998
|
+
if (this.#controller || this.#cancelTimer) return;
|
|
3651
3999
|
if (this.#status === RealtimeStatus.Disconnected) return;
|
|
3652
4000
|
this.#attempt = 0;
|
|
3653
4001
|
this.#run();
|
|
@@ -3669,6 +4017,9 @@ var ItdRealtime = class {
|
|
|
3669
4017
|
this.#emitter.emit("status", status);
|
|
3670
4018
|
}
|
|
3671
4019
|
};
|
|
4020
|
+
function assertNeverUpdate(update) {
|
|
4021
|
+
throw new TypeError(`Необработанное обновление потока: ${String(update)}`);
|
|
4022
|
+
}
|
|
3672
4023
|
//#endregion
|
|
3673
4024
|
//#region src/core/pagination.ts
|
|
3674
4025
|
/** Схема пагинации эндпоинта. */
|
|
@@ -4584,6 +4935,140 @@ var CommentsResource = class extends BaseResource {
|
|
|
4584
4935
|
}
|
|
4585
4936
|
};
|
|
4586
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
|
|
4587
5072
|
//#region src/core/mime.ts
|
|
4588
5073
|
/** Изображения, которые принимает `POST /api/files/upload`. */
|
|
4589
5074
|
const IMAGE_MIME_TYPES = Object.freeze([
|
|
@@ -4832,7 +5317,7 @@ var FilesResource = class extends BaseResource {
|
|
|
4832
5317
|
async #prepareBuffer(input, options, context) {
|
|
4833
5318
|
const content = await this.#resolveBuffer(input, context);
|
|
4834
5319
|
const filename = options.filename ?? content.filename ?? this.#nameFromMime(options.contentType ?? content.contentType);
|
|
4835
|
-
const contentType =
|
|
5320
|
+
const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(require_runtime.isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
|
|
4836
5321
|
if (options.validateMime !== false) assertAllowedMime(contentType, filename);
|
|
4837
5322
|
const blob = require_runtime.isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
|
|
4838
5323
|
const limits = require_multi_storage.resolveFileStreamOptions(options);
|
|
@@ -4853,7 +5338,7 @@ var FilesResource = class extends BaseResource {
|
|
|
4853
5338
|
if (typeof input === "object" && input !== null && "open" in input) opened = await input.open(context);
|
|
4854
5339
|
else if (typeof input === "object" && input !== null && "url" in input) {
|
|
4855
5340
|
const { url, ...urlOptions } = input;
|
|
4856
|
-
opened = await
|
|
5341
|
+
opened = await openUrlFile(url, urlOptions, context);
|
|
4857
5342
|
} else throw new require_storage.ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
|
|
4858
5343
|
if (!opened || typeof ReadableStream === "undefined" || !(opened.stream instanceof ReadableStream)) {
|
|
4859
5344
|
await opened?.close?.();
|
|
@@ -4861,7 +5346,7 @@ var FilesResource = class extends BaseResource {
|
|
|
4861
5346
|
}
|
|
4862
5347
|
try {
|
|
4863
5348
|
const filename = options.filename ?? opened.filename ?? this.#nameFromMime(options.contentType ?? opened.contentType);
|
|
4864
|
-
const contentType =
|
|
5349
|
+
const contentType = normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
|
|
4865
5350
|
if (options.validateMime !== false) assertAllowedMime(contentType, filename);
|
|
4866
5351
|
const limits = require_multi_storage.resolveFileStreamOptions({
|
|
4867
5352
|
mode: require_multi_storage.FileTransferMode.Stream,
|
|
@@ -4899,7 +5384,7 @@ var FilesResource = class extends BaseResource {
|
|
|
4899
5384
|
if ("load" in input) return input.load(context);
|
|
4900
5385
|
if ("url" in input) {
|
|
4901
5386
|
const { url, ...urlOptions } = input;
|
|
4902
|
-
return
|
|
5387
|
+
return downloadFile(url, urlOptions, context);
|
|
4903
5388
|
}
|
|
4904
5389
|
if ("file" in input) return input;
|
|
4905
5390
|
if ("open" in input) throw new require_storage.ItdConfigError("потоковый источник нельзя использовать как буферный");
|
|
@@ -4907,7 +5392,7 @@ var FilesResource = class extends BaseResource {
|
|
|
4907
5392
|
}
|
|
4908
5393
|
/** Подбирает непустое имя, обязательное для multipart. */
|
|
4909
5394
|
#nameFromMime(contentType) {
|
|
4910
|
-
const extension =
|
|
5395
|
+
const extension = normalizeMimeType(contentType)?.split("/")[1];
|
|
4911
5396
|
return extension ? `file.${extension}` : "file";
|
|
4912
5397
|
}
|
|
4913
5398
|
};
|
|
@@ -5129,7 +5614,6 @@ var NotificationsResource = class extends BaseResource {
|
|
|
5129
5614
|
};
|
|
5130
5615
|
//#endregion
|
|
5131
5616
|
//#region src/core/time.ts
|
|
5132
|
-
/** Отметка времени без часового пояса: `2026-07-23 23:14:25`, возможно с долями секунды. */
|
|
5133
5617
|
const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
|
|
5134
5618
|
/**
|
|
5135
5619
|
* Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC.
|
|
@@ -5148,6 +5632,21 @@ function utcStampToIso(value) {
|
|
|
5148
5632
|
const iso = `${match[1]}T${match[2]}Z`;
|
|
5149
5633
|
return Number.isFinite(Date.parse(iso)) ? iso : value;
|
|
5150
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
|
+
}
|
|
5151
5650
|
//#endregion
|
|
5152
5651
|
//#region src/resources/platform.ts
|
|
5153
5652
|
/** Приводит `last_checked` каждого сервиса к ISO. Остальное остаётся как прислал сервер. */
|
|
@@ -6078,7 +6577,7 @@ function resolvePoll(input) {
|
|
|
6078
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;
|
|
6079
6578
|
const BUILD_UPDATE = Symbol.for("itd.postBuilder.update");
|
|
6080
6579
|
/**
|
|
6081
|
-
* Проверяет данные поста.
|
|
6580
|
+
* Проверяет и нормализует данные поста.
|
|
6082
6581
|
*
|
|
6083
6582
|
* Отдельного внимания заслуживает `wallRecipientId`: API принимает там **только UUID**,
|
|
6084
6583
|
* а имя пользователя молча приводит к ошибке на сервере. Проверка здесь превращает
|
|
@@ -6098,10 +6597,11 @@ function validatePost(input) {
|
|
|
6098
6597
|
if (wallRecipientId !== void 0 && wallRecipientId !== null) {
|
|
6099
6598
|
if (!UUID_PATTERN.test(wallRecipientId)) throw new require_storage.ItdConfigError(`wallRecipientId должен быть UUID, а не именем пользователя (получено: «${wallRecipientId}»). Идентификатор можно взять из профиля: (await itd.users.get(username)).id`);
|
|
6100
6599
|
}
|
|
6600
|
+
const { poll: inputPoll, ...data } = input;
|
|
6101
6601
|
return {
|
|
6102
|
-
...
|
|
6602
|
+
...data,
|
|
6103
6603
|
...input.spans !== void 0 ? { spans: validateSpans(content, input.spans) } : {},
|
|
6104
|
-
...
|
|
6604
|
+
...inputPoll !== void 0 ? { poll: resolvePoll(inputPoll) } : {}
|
|
6105
6605
|
};
|
|
6106
6606
|
}
|
|
6107
6607
|
/**
|
|
@@ -6298,7 +6798,8 @@ function post(content) {
|
|
|
6298
6798
|
}
|
|
6299
6799
|
/** Приводит любую форму входа к готовым данным поста. */
|
|
6300
6800
|
function resolvePost(input) {
|
|
6301
|
-
|
|
6801
|
+
const resolved = typeof input === "function" ? input(post()) : input;
|
|
6802
|
+
return isBuilder(resolved) ? validatePost(resolved.build()) : validatePost(resolved);
|
|
6302
6803
|
}
|
|
6303
6804
|
function validatePostUpdate(input) {
|
|
6304
6805
|
if (!input || typeof input !== "object") throw new require_storage.ItdConfigError("Для обновления поста нужен объект с явно заданным content");
|
|
@@ -7573,7 +8074,7 @@ var ItdClient = class ItdClient {
|
|
|
7573
8074
|
for (const service of BUILT_IN_SERVICES) this.#services.define(service);
|
|
7574
8075
|
for (const service of config.services) this.#services.define(service);
|
|
7575
8076
|
const shared = config.rateLimit ? internals.queues : void 0;
|
|
7576
|
-
const queues = shared ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit) : void 0);
|
|
8077
|
+
const queues = shared ?? (config.rateLimit ? new RequestQueuePool(config.rateLimit, config.clock) : void 0);
|
|
7577
8078
|
this.#queues = queues;
|
|
7578
8079
|
this.#ownsQueues = shared === void 0;
|
|
7579
8080
|
let authManager;
|
|
@@ -7589,6 +8090,7 @@ var ItdClient = class ItdClient {
|
|
|
7589
8090
|
this.#transport = transport;
|
|
7590
8091
|
const pluginsLayer = createPluginsMiddleware(this.#plugins);
|
|
7591
8092
|
const retriesLayer = createRetryMiddleware({
|
|
8093
|
+
clock: config.clock,
|
|
7592
8094
|
retry: config.retry,
|
|
7593
8095
|
rateLimitDelays: config.rateLimit?.retryDelays ?? [],
|
|
7594
8096
|
pauseQueue: queues ? (ms, request) => queues.for(request.service).pause(ms) : void 0,
|
|
@@ -7768,10 +8270,12 @@ var ItdClient = class ItdClient {
|
|
|
7768
8270
|
*
|
|
7769
8271
|
* @example
|
|
7770
8272
|
* ```ts
|
|
8273
|
+
* import { NotificationType } from 'itd-api';
|
|
8274
|
+
*
|
|
7771
8275
|
* const stream = itd.realtime();
|
|
7772
8276
|
*
|
|
7773
|
-
* stream.
|
|
7774
|
-
*
|
|
8277
|
+
* stream.onNotification(NotificationType.PostComment, async ({ update }) => {
|
|
8278
|
+
* await handleComment(update.data.notification);
|
|
7775
8279
|
* });
|
|
7776
8280
|
* stream.on('unreadCount', (count) => setBadge(count));
|
|
7777
8281
|
*
|
|
@@ -7789,8 +8293,10 @@ var ItdClient = class ItdClient {
|
|
|
7789
8293
|
getToken: () => this.#authManager.getAccessToken(),
|
|
7790
8294
|
refresh: () => this.#authManager.onUnauthorized(),
|
|
7791
8295
|
fetchUnreadCount: () => this.notifications.count(),
|
|
8296
|
+
onConnect: () => this.#streams.add(stream),
|
|
7792
8297
|
onClose: () => this.#streams.delete(stream),
|
|
7793
|
-
logger: this.#config.logger
|
|
8298
|
+
logger: this.#config.logger,
|
|
8299
|
+
clock: this.#config.clock
|
|
7794
8300
|
}, options);
|
|
7795
8301
|
this.#streams.add(stream);
|
|
7796
8302
|
return stream;
|
|
@@ -7799,8 +8305,8 @@ var ItdClient = class ItdClient {
|
|
|
7799
8305
|
* Освобождает ресурсы клиента: закрывает все потоки уведомлений, отправляет открытые
|
|
7800
8306
|
* накопители {@link telemetry}, затем останавливает очередь запросов.
|
|
7801
8307
|
*
|
|
7802
|
-
* После вызова клиентом можно пользоваться
|
|
7803
|
-
*
|
|
8308
|
+
* Метод дожидается активных обработчиков потока. После вызова клиентом можно пользоваться
|
|
8309
|
+
* снова; ранее созданный поток можно запустить повторным `connect()`.
|
|
7804
8310
|
*
|
|
7805
8311
|
* Общая очередь, полученная от {@link ItdAccounts}, не останавливается: её гасит сам
|
|
7806
8312
|
* контейнер, когда закрывает все аккаунты разом.
|
|
@@ -7813,8 +8319,9 @@ var ItdClient = class ItdClient {
|
|
|
7813
8319
|
* ```
|
|
7814
8320
|
*/
|
|
7815
8321
|
async close() {
|
|
7816
|
-
this.#disconnectStreams();
|
|
8322
|
+
const streams = this.#disconnectStreams();
|
|
7817
8323
|
try {
|
|
8324
|
+
await Promise.all(streams.map((stream) => stream.drain()));
|
|
7818
8325
|
await this.telemetry.close();
|
|
7819
8326
|
} finally {
|
|
7820
8327
|
if (this.#ownsQueues) this.#queues?.stop();
|
|
@@ -7833,8 +8340,10 @@ var ItdClient = class ItdClient {
|
|
|
7833
8340
|
}
|
|
7834
8341
|
/** Завершает потоки до того, как запросы начнут использовать другой аккаунт. */
|
|
7835
8342
|
#disconnectStreams() {
|
|
7836
|
-
|
|
8343
|
+
const streams = [...this.#streams];
|
|
8344
|
+
for (const stream of streams) stream.disconnect();
|
|
7837
8345
|
this.#streams.clear();
|
|
8346
|
+
return streams;
|
|
7838
8347
|
}
|
|
7839
8348
|
/** Позволяет использовать клиент с `await using`. */
|
|
7840
8349
|
[Symbol.asyncDispose]() {
|
|
@@ -7973,7 +8482,7 @@ var ItdAccounts = class ItdAccounts {
|
|
|
7973
8482
|
this.#plugins = orderPluginDefinitions(plugins ?? []);
|
|
7974
8483
|
this.#rateLimitScope = rateLimitScope ?? "account";
|
|
7975
8484
|
const rateLimit = this.#rateLimitScope === "shared" ? resolveRateLimit(base.rateLimit) : void 0;
|
|
7976
|
-
this.#queues = rateLimit ? new RequestQueuePool(rateLimit) : void 0;
|
|
8485
|
+
this.#queues = rateLimit ? new RequestQueuePool(rateLimit, base.clock ?? systemClock) : void 0;
|
|
7977
8486
|
const logger = typeof base.logger === "object" ? base.logger : void 0;
|
|
7978
8487
|
this.#logger = logger;
|
|
7979
8488
|
this.#emitter = new Emitter((error) => reportListenerError(logger, "аккаунтов", error));
|
|
@@ -8302,6 +8811,92 @@ function createAccounts(options = {}) {
|
|
|
8302
8811
|
return new ItdAccounts(options);
|
|
8303
8812
|
}
|
|
8304
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
|
|
8305
8900
|
//#region src/notifications/text.ts
|
|
8306
8901
|
/** Имя, которое подставляется, если участник неизвестен. */
|
|
8307
8902
|
const UNKNOWN_ACTOR = "Пользователь";
|
|
@@ -8433,6 +9028,87 @@ function resolveNotificationUrl(notification) {
|
|
|
8433
9028
|
return clickUrl || "/notifications";
|
|
8434
9029
|
}
|
|
8435
9030
|
//#endregion
|
|
9031
|
+
//#region src/realtime/router.ts
|
|
9032
|
+
/**
|
|
9033
|
+
* Направляет обновления потока в именованные цепочки промежуточных обработчиков.
|
|
9034
|
+
*
|
|
9035
|
+
* @example
|
|
9036
|
+
* ```ts
|
|
9037
|
+
* import { RealtimeRouter, RealtimeUpdateType } from 'itd-api';
|
|
9038
|
+
*
|
|
9039
|
+
* const router = new RealtimeRouter((context) => context.update.type);
|
|
9040
|
+
* router.route(RealtimeUpdateType.Notification, async (context, next) => {
|
|
9041
|
+
* if (context.update.type === RealtimeUpdateType.Notification) {
|
|
9042
|
+
* await handleNotification(context.update.data.notification);
|
|
9043
|
+
* }
|
|
9044
|
+
* await next();
|
|
9045
|
+
* });
|
|
9046
|
+
* stream.use(router.middleware());
|
|
9047
|
+
* ```
|
|
9048
|
+
*/
|
|
9049
|
+
var RealtimeRouter = class {
|
|
9050
|
+
#selector;
|
|
9051
|
+
#routes = /* @__PURE__ */ new Map();
|
|
9052
|
+
#fallback = [];
|
|
9053
|
+
constructor(selector) {
|
|
9054
|
+
if (typeof selector !== "function") throw new require_storage.ItdConfigError("RealtimeRouter принимает функцию выбора маршрута");
|
|
9055
|
+
this.#selector = selector;
|
|
9056
|
+
}
|
|
9057
|
+
/** Добавляет промежуточные обработчики к маршруту и возвращает функцию их удаления. */
|
|
9058
|
+
route(key, ...middleware) {
|
|
9059
|
+
if (!isPropertyKey(key)) throw new require_storage.ItdConfigError("Ключ realtime route должен быть PropertyKey");
|
|
9060
|
+
const registration = this.#registration(middleware);
|
|
9061
|
+
const registrations = this.#routes.get(key) ?? [];
|
|
9062
|
+
registrations.push(registration);
|
|
9063
|
+
this.#routes.set(key, registrations);
|
|
9064
|
+
return () => {
|
|
9065
|
+
const current = this.#routes.get(key);
|
|
9066
|
+
if (!current) return;
|
|
9067
|
+
const index = current.indexOf(registration);
|
|
9068
|
+
if (index >= 0) current.splice(index, 1);
|
|
9069
|
+
if (current.length === 0) this.#routes.delete(key);
|
|
9070
|
+
};
|
|
9071
|
+
}
|
|
9072
|
+
/** Добавляет промежуточные обработчики для обновлений без зарегистрированного маршрута. */
|
|
9073
|
+
otherwise(...middleware) {
|
|
9074
|
+
const registration = this.#registration(middleware);
|
|
9075
|
+
this.#fallback.push(registration);
|
|
9076
|
+
return () => {
|
|
9077
|
+
const index = this.#fallback.indexOf(registration);
|
|
9078
|
+
if (index >= 0) this.#fallback.splice(index, 1);
|
|
9079
|
+
};
|
|
9080
|
+
}
|
|
9081
|
+
/** Возвращает промежуточный обработчик для `stream.use()`. */
|
|
9082
|
+
middleware() {
|
|
9083
|
+
const middleware = (context, next) => this.#captureMiddleware()(context, next);
|
|
9084
|
+
return withRealtimeMiddlewareSnapshot(middleware, () => this.#captureMiddleware());
|
|
9085
|
+
}
|
|
9086
|
+
#captureMiddleware() {
|
|
9087
|
+
const routes = /* @__PURE__ */ new Map();
|
|
9088
|
+
for (const [key, registrations] of this.#routes) routes.set(key, registrations.flatMap(({ middleware }) => middleware).map(captureRealtimeMiddleware));
|
|
9089
|
+
const fallback = this.#fallback.flatMap(({ middleware }) => middleware).map(captureRealtimeMiddleware);
|
|
9090
|
+
return async (context, next) => {
|
|
9091
|
+
const key = await this.#selector(context);
|
|
9092
|
+
if (key != null && !isPropertyKey(key)) throw new require_storage.ItdConfigError("Функция выбора маршрута должна возвращать PropertyKey, null или undefined");
|
|
9093
|
+
const route = key == null ? void 0 : routes.get(key);
|
|
9094
|
+
const chain = route && route.length > 0 ? route : fallback;
|
|
9095
|
+
if (chain.length === 0) {
|
|
9096
|
+
await next();
|
|
9097
|
+
return;
|
|
9098
|
+
}
|
|
9099
|
+
await runRealtimeMiddleware(chain, context, next);
|
|
9100
|
+
};
|
|
9101
|
+
}
|
|
9102
|
+
#registration(middleware) {
|
|
9103
|
+
if (middleware.length === 0) throw new require_storage.ItdConfigError("Маршрут должен содержать хотя бы один обработчик");
|
|
9104
|
+
for (const item of middleware) if (typeof item !== "function") throw new require_storage.ItdConfigError("Маршрут принимает только функции обработки");
|
|
9105
|
+
return { middleware: [...middleware] };
|
|
9106
|
+
}
|
|
9107
|
+
};
|
|
9108
|
+
function isPropertyKey(value) {
|
|
9109
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "symbol";
|
|
9110
|
+
}
|
|
9111
|
+
//#endregion
|
|
8436
9112
|
//#region src/spans/render.ts
|
|
8437
9113
|
/** Формат результата {@link renderSpans}. */
|
|
8438
9114
|
const SpanRenderFormat = Object.freeze({
|
|
@@ -8637,54 +9313,6 @@ function renderSpans(content, spans = [], options = {}) {
|
|
|
8637
9313
|
return result;
|
|
8638
9314
|
}
|
|
8639
9315
|
//#endregion
|
|
8640
|
-
//#region src/types/models.ts
|
|
8641
|
-
/**
|
|
8642
|
-
* Свой ли это профиль.
|
|
8643
|
-
*
|
|
8644
|
-
* @example
|
|
8645
|
-
* ```ts
|
|
8646
|
-
* if (isMyProfile(profile)) console.log(profile.subscription.isActive);
|
|
8647
|
-
* ```
|
|
8648
|
-
*/
|
|
8649
|
-
function isMyProfile(profile) {
|
|
8650
|
-
return "subscription" in profile;
|
|
8651
|
-
}
|
|
8652
|
-
/** Глубина истории статуса в сутках. Столько элементов отдаёт {@link statusDays}. */
|
|
8653
|
-
const STATUS_WINDOW_DAYS = 90;
|
|
8654
|
-
/**
|
|
8655
|
-
* Разбирает дату API в объект `Date`.
|
|
8656
|
-
*
|
|
8657
|
-
* @returns `null`, если строки нет или она не разбирается
|
|
8658
|
-
*
|
|
8659
|
-
* @example
|
|
8660
|
-
* ```ts
|
|
8661
|
-
* const created = toDate(post.createdAt);
|
|
8662
|
-
* ```
|
|
8663
|
-
*/
|
|
8664
|
-
function toDate(value) {
|
|
8665
|
-
if (!value) return null;
|
|
8666
|
-
const date = new Date(value);
|
|
8667
|
-
return Number.isFinite(date.getTime()) ? date : null;
|
|
8668
|
-
}
|
|
8669
|
-
/**
|
|
8670
|
-
* Разворачивает историю сервиса в массив на 90 суток.
|
|
8671
|
-
* Сутки без данных становятся `null`.
|
|
8672
|
-
*
|
|
8673
|
-
* @returns массив, где индекс — сколько суток назад: `[0]` — сегодня
|
|
8674
|
-
*
|
|
8675
|
-
* @example
|
|
8676
|
-
* ```ts
|
|
8677
|
-
* const status = await itd.platform.status();
|
|
8678
|
-
* const days = statusDays(status.services[0]);
|
|
8679
|
-
*
|
|
8680
|
-
* days[0]?.uptime; // доступность за сегодня
|
|
8681
|
-
* days.filter((day) => day === null).length; // за сколько суток данных нет
|
|
8682
|
-
* ```
|
|
8683
|
-
*/
|
|
8684
|
-
function statusDays(service) {
|
|
8685
|
-
return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
|
|
8686
|
-
}
|
|
8687
|
-
//#endregion
|
|
8688
9316
|
exports.ALLOWED_MIME_TYPES = ALLOWED_MIME_TYPES;
|
|
8689
9317
|
exports.AUDIO_MIME_TYPES = AUDIO_MIME_TYPES;
|
|
8690
9318
|
exports.AUTH_FLAG_COOKIE = require_multi_storage.AUTH_FLAG_COOKIE;
|
|
@@ -8743,8 +9371,11 @@ exports.RECONNECT_JITTER = RECONNECT_JITTER;
|
|
|
8743
9371
|
exports.REFRESH_COOKIE = require_multi_storage.REFRESH_COOKIE;
|
|
8744
9372
|
exports.REFRESH_COOKIE_PATH = require_multi_storage.REFRESH_COOKIE_PATH;
|
|
8745
9373
|
exports.REQUEST_OPTION_KEYS = REQUEST_OPTION_KEYS;
|
|
9374
|
+
exports.RealtimeRouter = RealtimeRouter;
|
|
8746
9375
|
exports.RealtimeStatus = RealtimeStatus;
|
|
8747
9376
|
exports.RealtimeTransportKind = RealtimeTransportKind;
|
|
9377
|
+
exports.RealtimeUpdateOrigin = RealtimeUpdateOrigin;
|
|
9378
|
+
exports.RealtimeUpdateType = RealtimeUpdateType;
|
|
8748
9379
|
exports.ReportReason = ReportReason;
|
|
8749
9380
|
exports.ReportTargetType = ReportTargetType;
|
|
8750
9381
|
exports.RuntimeMode = require_runtime.RuntimeMode;
|
|
@@ -8770,8 +9401,8 @@ exports.createMultiTokenStorage = require_multi_storage.createMultiTokenStorage;
|
|
|
8770
9401
|
exports.createRecordMultiStorage = require_multi_storage.createRecordMultiStorage;
|
|
8771
9402
|
exports.createTokenStorage = require_storage.createTokenStorage;
|
|
8772
9403
|
exports.formatNotificationText = formatNotificationText;
|
|
8773
|
-
exports.fromStream =
|
|
8774
|
-
exports.fromUrl =
|
|
9404
|
+
exports.fromStream = fromStream;
|
|
9405
|
+
exports.fromUrl = fromUrl;
|
|
8775
9406
|
exports.isBuilder = isBuilder;
|
|
8776
9407
|
exports.isItdApiError = require_storage.isItdApiError;
|
|
8777
9408
|
exports.isItdAuthError = require_storage.isItdAuthError;
|
|
@@ -8800,6 +9431,7 @@ exports.report = report;
|
|
|
8800
9431
|
exports.resolveNotificationUrl = resolveNotificationUrl;
|
|
8801
9432
|
exports.scopedTokenStorage = require_multi_storage.scopedTokenStorage;
|
|
8802
9433
|
exports.statusDays = statusDays;
|
|
9434
|
+
exports.systemClock = systemClock;
|
|
8803
9435
|
exports.toDate = toDate;
|
|
8804
9436
|
exports.utcStampToIso = utcStampToIso;
|
|
8805
9437
|
|