UNPKG

synapse-storage

Version:

Набор инструментов для управления состоянием и апи-запросами

425 lines (412 loc) 19.5 kB
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