synapse-storage
Version:
Набор инструментов для управления состоянием и апи-запросами
425 lines (412 loc) • 19.5 kB
JavaScript
import { createUniqueId, headersToObject } from "../utils/api-helpers.js";
import { createHeaderContext } from "../utils/create-header-context.js";
import { createPrepareHeaders, prepareRequestHeaders } from "../utils/endpoint-headers.js";
import { fetchBaseQuery } from "../utils/fetch-base-query.js";
import { getCacheableHeaders } from "../utils/get-cacheable-headers.js";
/** HTTP-статусы, при которых делать retry по умолчанию */ const DEFAULT_RETRY_ON = [
0,
408,
429,
500,
502,
503,
504
];
class EndpointClass {
endpointSubscribers = new Set();
/** Сколько раз был вызван метод request */ fetchCounts = 0;
meta = {
cache: false,
invalidatesTags: [],
name: '',
tags: []
};
name;
queryStorage;
configCurrentEndpoint;
cacheableHeaderKeys;
globalRetryConfig;
baseQueryConfig;
queryFunction;
/** Массив заголовков, которые нужно включить в ключ кэширования */ cacheableHeaders;
prepareHeaders;
/** Карта in-flight запросов для дедупликации (cacheKey → Promise) */ inflightRequests = new Map();
constructor(options){
this.name = options.name;
this.queryStorage = options.queryStorage;
this.configCurrentEndpoint = options.config;
this.cacheableHeaderKeys = options.cacheableHeaderKeys;
this.globalRetryConfig = options.globalRetryConfig;
this.baseQueryConfig = options.baseQueryConfig;
// 1. Создаем функцию подготовки заголовков
this.prepareHeaders = createPrepareHeaders(this.baseQueryConfig.prepareHeaders, this.configCurrentEndpoint.prepareHeaders);
// 2. Создаем функцию исполнения запроса
this.queryFunction = fetchBaseQuery({
baseUrl: this.baseQueryConfig.baseUrl,
fetchFn: this.baseQueryConfig.fetchFn,
timeout: this.baseQueryConfig.timeout,
credentials: this.baseQueryConfig.credentials
});
// 3. Создаем массив тех заголовков, которые нужно включить в ключ кэширования
this.cacheableHeaders = [
...this.cacheableHeaderKeys || [],
...this.configCurrentEndpoint.includeCacheableHeaderKeys || []
].filter((key)=>!this.configCurrentEndpoint.excludeCacheableHeaderKeys?.includes(key));
// 4. Сохраняем информацию в meta
this.meta.name = this.name;
this.meta.tags = this.configCurrentEndpoint.tags ?? this.meta.tags;
this.meta.invalidatesTags = this.configCurrentEndpoint.invalidatesTags ?? this.meta.invalidatesTags;
this.meta.cache = this.queryStorage.createCacheConfig(this.configCurrentEndpoint) ?? this.meta.cache;
}
request(params, options) {
// 1. Подготовка и инициализация
this.fetchCounts++;
const requestId = createUniqueId(this.name);
const controller = new AbortController();
const requestSubscribers = new Set();
const currentState = {
status: 'idle',
requestParams: params,
headers: {},
error: undefined,
data: undefined,
fromCache: false
};
// Связываем пользовательский signal с внутренним controller
if (options?.signal) {
if (options.signal.aborted) {
controller.abort();
} else {
options.signal.addEventListener('abort', ()=>controller.abort(), {
once: true
});
}
}
// 2. Функция нотификации подписчиков запроса
const notifyRequestSubscribers = (newState)=>{
Object.assign(currentState, newState);
requestSubscribers.forEach((cb)=>cb({
...currentState
}));
};
// 3. Запускаем выполнение запроса
const waitPromise = this.executeRequest(params, options, controller, notifyRequestSubscribers);
// 4. Возвращаем объект с методами управления запросом
return {
id: requestId,
subscribe (listener, subscribeOptions = {}) {
const { autoUnsubscribe = true } = subscribeOptions;
requestSubscribers.add(listener);
listener(currentState);
const unsubscribe = ()=>requestSubscribers.delete(listener);
if (autoUnsubscribe) {
waitPromise.finally(()=>unsubscribe());
}
return unsubscribe;
},
wait: ()=>waitPromise,
waitWithCallbacks (handlers = {}) {
const { idle, loading, success, error } = handlers;
this.subscribe((state)=>{
switch(state.status){
case 'idle':
idle?.(state);
break;
case 'loading':
loading?.(state);
break;
case 'success':
success?.(state.data, state);
break;
case 'error':
error?.(state.error, state);
break;
}
}, {
autoUnsubscribe: true
});
return waitPromise;
},
abort: ()=>{
if (!controller.signal.aborted) {
controller.abort();
}
},
then: (onfulfilled, onrejected)=>waitPromise.then(onfulfilled, onrejected),
catch: (onrejected)=>waitPromise.catch(onrejected),
finally: (onfinally)=>waitPromise.finally(onfinally)
};
}
/**
* Определяет итоговую конфигурацию retry: вызов → эндпоинт → глобальная
*/ resolveRetryConfig(options) {
return options?.retry ?? this.configCurrentEndpoint.retry ?? this.globalRetryConfig;
}
/**
* Выполняет сетевой запрос с кэшированием, дедупликацией и retry
*/ async executeRequest(params, options, controller, notify) {
const headerContext = createHeaderContext({
requestParams: params
}, options?.context || {});
try {
// 1. Формируем заголовки
const headers = await prepareRequestHeaders(this.prepareHeaders, headerContext);
const headersForCache = getCacheableHeaders(headers, options?.cacheableHeaderKeys ? options.cacheableHeaderKeys : this.cacheableHeaders);
// 2. Формируем requestDefinition для определения метода
const requestDefinition = this.configCurrentEndpoint.request(params, options?.context);
// 3. Проверяем кэширование (с учётом HTTP-метода)
const shouldCache = this.queryStorage.shouldCache(this.configCurrentEndpoint, options, requestDefinition.method);
const [cacheKey, cacheParams] = this.queryStorage.createCacheKey(this.name, {
...params,
...headersForCache
});
const cacheKeyStr = String(cacheKey);
// 4. Проверяем кэш
if (shouldCache) {
const cachedResult = await this.queryStorage.getCachedResult(cacheKey);
if (cachedResult) {
notify({
fromCache: true,
status: 'success',
data: cachedResult.data,
error: undefined,
headers: cachedResult.headers,
requestParams: params
});
return {
...cachedResult,
fromCache: true
};
}
}
// 5. Дедупликация: если запрос с таким же ключом уже летит — ждём его
if (shouldCache && this.inflightRequests.has(cacheKeyStr)) {
notify({
fromCache: false,
status: 'loading'
});
const result = await this.inflightRequests.get(cacheKeyStr);
if (!result.ok) {
notify({
fromCache: true,
status: 'error',
data: undefined,
error: result.error,
headers: result.headers,
requestParams: params
});
return {
...result,
fromCache: true
};
}
notify({
fromCache: true,
status: 'success',
data: result.data,
error: undefined,
headers: result.headers,
requestParams: params
});
return {
...result,
fromCache: true
};
}
// 6. Выполняем запрос (с retry и post-processing)
notify({
fromCache: false,
status: 'loading'
});
const retryConfig = this.resolveRetryConfig(options);
const fetchPromise = this.executeFetch(requestDefinition, options, controller, headers, retryConfig, shouldCache, cacheKey, cacheParams ?? {});
// Регистрируем в inflight для дедупликации (только для кэшируемых)
if (shouldCache) {
this.inflightRequests.set(cacheKeyStr, fetchPromise);
fetchPromise.finally(()=>this.inflightRequests.delete(cacheKeyStr)).catch(()=>{});
}
const response = await fetchPromise;
// 7. Обрабатываем результат
if (response.ok) {
notify({
fromCache: false,
status: 'success',
data: response.data,
error: undefined,
headers: response.headers,
requestParams: params
});
this.notifyEndpointSubscribers('success');
return {
...response,
fromCache: false
};
} else {
// invalidateOnError: инвалидируем кэш при ошибке если включено
if (shouldCache) {
const cacheConfig = this.queryStorage.createCacheConfig(this.configCurrentEndpoint);
if (cacheConfig.invalidateOnError !== false) {
await this.queryStorage.invalidateCache(cacheKey);
}
}
notify({
fromCache: false,
status: 'error',
data: undefined,
error: response.error,
headers: response.headers,
requestParams: params
});
this.notifyEndpointSubscribers('error', response.error);
throw response.error;
}
} catch (error) {
notify({
fromCache: false,
status: 'error',
data: undefined,
error: error,
headers: undefined,
requestParams: params
});
throw error;
}
}
/**
* Выполняет HTTP-запрос с retry, инвалидацией тегов и кэшированием результата
*/ async executeFetch(requestDefinition, options, controller, headers, retryConfig, shouldCache, cacheKey, cacheParams) {
// Выполняем HTTP-запрос (с retry если настроен)
const response = await this.fetchWithRetry(requestDefinition, options, controller, headers, retryConfig);
// Post-processing при успешном ответе
if (response.ok) {
const { headers: responseHeaders, ...restResponse } = response;
// Инвалидируем кэш по тегам
if (this.configCurrentEndpoint.invalidatesTags?.length) {
await this.queryStorage.invalidateCacheByTags(this.configCurrentEndpoint.invalidatesTags);
}
// Сохраняем в кэш
if (shouldCache) {
const currentCacheConfig = this.queryStorage.createCacheConfig(this.configCurrentEndpoint);
await this.queryStorage.setCachedResult(cacheKey, {
...restResponse,
headers: headersToObject(responseHeaders)
}, currentCacheConfig, cacheParams, this.configCurrentEndpoint.tags ?? []);
}
}
return response;
}
/**
* Выполняет HTTP-запрос с повторными попытками
*/ async fetchWithRetry(requestDefinition, options, controller, headers, retryConfig) {
const maxAttempts = (retryConfig?.count ?? 0) + 1;
const retryOn = retryConfig?.retryOn ?? DEFAULT_RETRY_ON;
const getDelay = (attempt)=>{
if (typeof retryConfig?.delay === 'function') return retryConfig.delay(attempt);
return retryConfig?.delay ?? 1000;
};
let lastResponse;
for(let attempt = 0; attempt < maxAttempts; attempt++){
// Если запрос отменён — бросаем ошибку (перехватывается в executeRequest)
if (controller.signal.aborted) throw new DOMException('The operation was aborted.', 'AbortError');
const mergedOptions = {
...options,
signal: controller.signal
};
lastResponse = await this.queryFunction(requestDefinition, mergedOptions, headers);
// Успех или не-retryable статус — возвращаем сразу
if (lastResponse.ok || !retryOn.includes(lastResponse.status) || attempt === maxAttempts - 1) {
return lastResponse;
}
// Ждём перед следующей попыткой
const delay = getDelay(attempt);
await new Promise((resolve)=>{
const timer = setTimeout(resolve, delay);
// Если запрос отменили во время ожидания — прерываем delay
controller.signal.addEventListener('abort', ()=>{
clearTimeout(timer);
resolve();
}, {
once: true
});
});
}
return lastResponse;
}
/**
* Уведомляет подписчиков эндпоинта об изменении состояния
*/ notifyEndpointSubscribers(status, error) {
const endpointState = {
status,
fetchCounts: this.fetchCounts,
meta: this.meta,
cacheableHeaders: this.cacheableHeaders,
error
};
this.endpointSubscribers.forEach((cb)=>cb(endpointState));
}
/**
* Синхронное чтение результата из кэша (fast-path для SSR-гидрации).
*
* Возвращает закэшированный результат БЕЗ сетевого запроса и без async-тика,
* поэтому хук может отдать серверные данные уже на первом рендере (без вспышки
* loading после гидрации). Работает только когда:
* - хранилище синхронное (Memory/LocalStorage);
* - у эндпоинта нет заголовков, влияющих на ключ кэша (`cacheableHeaders`) —
* иначе ключ нельзя воспроизвести синхронно (заголовки готовятся async);
* - кэширование для эндпоинта включено и запись не протухла.
*
* Во всех остальных случаях возвращает `undefined` — вызывающий откатывается
* на обычный async-`request()`.
*/ getCachedSync(params) {
// Ключ кэша зависит от заголовков, а они готовятся асинхронно — синхронно
// воспроизвести ключ нельзя. Поддерживаем fast-path только без таких заголовков.
if (this.cacheableHeaders.length > 0) return undefined;
if (!this.queryStorage.shouldCache(this.configCurrentEndpoint, undefined, 'GET')) return undefined;
const [cacheKey] = this.queryStorage.createCacheKey(this.name, {
...params
});
const cached = this.queryStorage.getCachedResultSync(cacheKey);
if (!cached) return undefined;
return {
...cached,
fromCache: true
};
}
/**
* Подписка на инвалидацию кэша, затрагивающую этот эндпоинт. Колбэк вызывается,
* когда инвалидируется любой из тегов эндпоинта (`meta.tags`) — например, после
* мутации соседнего эндпоинта с `invalidatesTags`. Используется хуком `useApiQuery`
* для авто-рефетча (паритет с поведением React Query).
*/ onCacheInvalidate(listener) {
return this.queryStorage.onCacheInvalidate((invalidatedTags)=>{
if (this.meta.tags.some((tag)=>invalidatedTags.includes(tag))) {
listener();
}
});
}
subscribe(cb) {
this.endpointSubscribers.add(cb);
const currentState = {
status: 'idle',
fetchCounts: this.fetchCounts,
meta: this.meta,
cacheableHeaders: this.cacheableHeaders,
error: undefined
};
cb(currentState);
return ()=>this.endpointSubscribers.delete(cb);
}
async reset() {
this.fetchCounts = 0;
// Инвалидируем кэш по тегам эндпоинта
if (this.meta.tags.length) {
await this.queryStorage.invalidateCacheByTags(this.meta.tags);
}
}
destroy() {
this.endpointSubscribers.clear();
this.inflightRequests.clear();
}
}
export { EndpointClass };
//# sourceMappingURL=endpoint.js.map