diff --git a/.eslintrc b/.eslintrc index fce7b97..7d8ba37 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,11 @@ "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], "root": true, + "env": { + "browser": true, + "amd": true, + "node": true + }, "rules": { "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-empty-function": "off", diff --git a/src/axios/cache.ts b/src/axios/cache.ts index 190aeb4..c194668 100644 --- a/src/axios/cache.ts +++ b/src/axios/cache.ts @@ -21,7 +21,7 @@ export function createCache( axiosCache.defaults = { ...axios.defaults, cache: { - maxAge: 1000 * 60 * 5, + ttl: 1000 * 60 * 5, interpretHeader: false, methods: ['get'], cachePredicate: ({ status }) => status >= 200 && status < 300, diff --git a/src/axios/types.ts b/src/axios/types.ts index e9f8344..f997cd6 100644 --- a/src/axios/types.ts +++ b/src/axios/types.ts @@ -25,13 +25,15 @@ export type CacheProperties = { /** * The time until the cached value is expired in milliseconds. * - * @default 1000 * 60 * 5 + * **Note**: a custom storage implementation may not respect this. + * + * @default 1000 * 60 * 5 // 5 Minutes */ - maxAge: number; + ttl: number; /** * If this interceptor should configure the cache from the request cache header - * When used, the maxAge property is ignored + * When used, the ttl property is ignored * * @default false */ diff --git a/src/interceptors/request.ts b/src/interceptors/request.ts index 4b5a289..771f756 100644 --- a/src/interceptors/request.ts +++ b/src/interceptors/request.ts @@ -14,6 +14,8 @@ export function applyRequestInterceptor(axios: AxiosCacheInstance): void { } const key = axios.generateKey(config); + + // Assumes that the storage handled staled responses const cache = await axios.storage.get(key); // Not cached, continue the request, and mark it as fetching @@ -22,17 +24,13 @@ export function applyRequestInterceptor(axios: AxiosCacheInstance): void { axios.waiting[key] = new Deferred(); await axios.storage.set(key, { - state: 'loading' + state: 'loading', + ttl: config.cache?.ttl }); return config; } - if (cache.state === 'cached' && cache.expiration < Date.now()) { - await axios.storage.remove(key); - return config; - } - let data: CachedResponse = {}; if (cache.state === 'loading') { diff --git a/src/interceptors/response.ts b/src/interceptors/response.ts index 0feb86e..328052a 100644 --- a/src/interceptors/response.ts +++ b/src/interceptors/response.ts @@ -30,7 +30,7 @@ export function applyResponseInterceptor(axios: AxiosCacheInstance): void { return response; } - let expiration = Date.now() + (response.config.cache?.maxAge || axios.defaults.cache.maxAge); + let ttl = response.config.cache?.ttl || axios.defaults.cache.ttl; if (response.config.cache?.interpretHeader) { const expirationTime = axios.headerInterpreter(response.headers['cache-control']); @@ -42,13 +42,14 @@ export function applyResponseInterceptor(axios: AxiosCacheInstance): void { return response; } - expiration = expirationTime ? expirationTime : expiration; + ttl = expirationTime ? expirationTime : ttl; } const newCache: CachedStorageValue = { data: { body: response.data, headers: response.headers }, state: 'cached', - expiration: expiration + ttl: ttl, + createdAt: Date.now() }; // Update other entries before updating himself diff --git a/src/storage/memory.ts b/src/storage/memory.ts index b69871b..659deb9 100644 --- a/src/storage/memory.ts +++ b/src/storage/memory.ts @@ -1,19 +1,21 @@ -import { EmptyStorageValue } from '.'; import { CacheStorage, StorageValue } from './types'; export class MemoryStorage implements CacheStorage { - readonly storage: Map = new Map(); + private readonly storage: Map = new Map(); get = async (key: string): Promise => { const value = this.storage.get(key); - if (value) { - return value; + if (!value) { + return { state: 'empty' }; } - const empty: EmptyStorageValue = { state: 'empty' }; - this.storage.set(key, empty); - return empty; + if (value.state === 'cached' && value.createdAt + value.ttl < Date.now()) { + this.remove(key); + return { state: 'empty' }; + } + + return value; }; set = async (key: string, value: StorageValue): Promise => { diff --git a/src/storage/types.ts b/src/storage/types.ts index afa036f..2e26569 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -1,15 +1,17 @@ export interface CacheStorage { /** - * Returns the cached value for the given key. Should return a 'empty' - * state StorageValue if the key does not exist. + * Returns the cached value for the given key. + * Must handle cache miss and staling by returning a new `StorageValue` with `empty` state. */ get: (key: string) => Promise; + /** * Sets a new value for the given key * * Use CacheStorage.remove(key) to define a key to 'empty' state. */ set: (key: string, value: LoadingStorageValue | CachedStorageValue) => Promise; + /** * Removes the value for the given key */ @@ -28,18 +30,29 @@ export type StorageValue = CachedStorageValue | LoadingStorageValue | EmptyStora export type CachedStorageValue = { data: CachedResponse; - expiration: number; + ttl: number; + createdAt: number; state: 'cached'; }; export type LoadingStorageValue = { data?: undefined; - expiration?: undefined; + ttl?: number; + + /** + * Defined when the state is cached + */ + createdAt?: undefined; state: 'loading'; }; export type EmptyStorageValue = { data?: undefined; - expiration?: undefined; + ttl?: undefined; + + /** + * Defined when the state is cached + */ + createdAt?: undefined; state: 'empty'; }; diff --git a/src/storage/web.ts b/src/storage/web.ts index 8c8a3ae..d60a3ac 100644 --- a/src/storage/web.ts +++ b/src/storage/web.ts @@ -5,9 +5,22 @@ import { CacheStorage, StorageValue } from './types'; export abstract class WindowStorageWrapper implements CacheStorage { constructor(readonly storage: Storage, readonly prefix: string = 'axios-cache:') {} - get = async (key: string): Promise => { - const json = this.storage.getItem(this.prefix + key); - return json ? JSON.parse(json) : { state: 'empty' }; + get = async (_key: string): Promise => { + const key = this.prefix + _key; + const json = this.storage.getItem(key); + + if (!json) { + return { state: 'empty' }; + } + + const parsed = JSON.parse(json); + + if (parsed.state === 'cached' && parsed.createdAt + parsed.ttl < Date.now()) { + this.storage.removeItem(key); + return { state: 'empty' }; + } + + return parsed; }; set = async (key: string, value: StorageValue): Promise => { @@ -22,7 +35,7 @@ export abstract class WindowStorageWrapper implements CacheStorage { export class LocalCacheStorage extends WindowStorageWrapper { constructor(prefix?: string) { - super(window.localStorage, prefix); + super(window.localStorage || localStorage, prefix); } } diff --git a/test/storage/common.test.ts b/test/storage/common.test.ts new file mode 100644 index 0000000..f53de1f --- /dev/null +++ b/test/storage/common.test.ts @@ -0,0 +1,6 @@ +import { MemoryStorage } from '../../src/storage'; +import { testStorage } from './storages'; + +describe('tests common storages', () => { + testStorage('memory', MemoryStorage); +}); diff --git a/test/storage/storages.ts b/test/storage/storages.ts new file mode 100644 index 0000000..fa08ecd --- /dev/null +++ b/test/storage/storages.ts @@ -0,0 +1,60 @@ +import { CacheStorage } from '../../src/storage/types'; + +export function testStorage(name: string, Storage: { new (): CacheStorage }) { + it(`tests ${name} storage methods`, async () => { + const storage = new Storage(); + + const result = await storage.get('key'); + + expect(result).not.toBeNull(); + expect(result.state).toBe('empty'); + + await storage.set('key', { + state: 'cached', + createdAt: Date.now(), + ttl: 1000 * 60 * 5, + data: { body: 'data', headers: {} } + }); + + const result2 = await storage.get('key'); + + expect(result2).not.toBeNull(); + expect(result2.state).toBe('cached'); + expect(result2.data?.body).toBe('data'); + + await storage.remove('key'); + + const result3 = await storage.get('key'); + + expect(result3).not.toBeNull(); + expect(result3.state).toBe('empty'); + }); + + it(`tests ${name} storage staling`, async () => { + jest.useFakeTimers('modern'); + const storage = new Storage(); + + await storage.set('key', { + state: 'cached', + createdAt: Date.now(), + ttl: 1000 * 60 * 5, // 5 Minutes + data: { body: 'data', headers: {} } + }); + + const result = await storage.get('key'); + + expect(result).not.toBeNull(); + expect(result.state).toBe('cached'); + expect(result.data?.body).toBe('data'); + + // Advance 6 minutes in time + jest.setSystemTime(Date.now() + 1000 * 60 * 6); + + const result2 = await storage.get('key'); + + expect(result2).not.toBeNull(); + expect(result2.state).toBe('empty'); + + jest.useRealTimers(); + }); +} diff --git a/test/storage/web.test.ts b/test/storage/web.test.ts new file mode 100644 index 0000000..74a89b0 --- /dev/null +++ b/test/storage/web.test.ts @@ -0,0 +1,11 @@ +/** + * @jest-environment jsdom + */ + +import { LocalCacheStorage, SessionCacheStorage } from '../../src/storage'; +import { testStorage } from './storages'; + +describe('tests web storages', () => { + testStorage('local-storage', LocalCacheStorage); + testStorage('session-storage', SessionCacheStorage); +}); diff --git a/test/util/status-codes.test.ts b/test/util/status-codes.test.ts index 9dad1a2..d76147d 100644 --- a/test/util/status-codes.test.ts +++ b/test/util/status-codes.test.ts @@ -12,7 +12,8 @@ describe('Tests cached status code', () => { axios.storage.set(KEY, { data: { body: true }, - expiration: Infinity, + ttl: Infinity, + createdAt: Date.now(), state: 'cached' }); }); diff --git a/test/util/update-cache.test.ts b/test/util/update-cache.test.ts index 1c3ce82..6e257ca 100644 --- a/test/util/update-cache.test.ts +++ b/test/util/update-cache.test.ts @@ -1,11 +1,16 @@ -import { AxiosCacheInstance, StorageValue } from '../../src'; +import { AxiosCacheInstance, CachedStorageValue } from '../../src'; import { updateCache } from '../../src/util/update-cache'; import { mockAxios } from '../mocks/axios'; const KEY = 'cacheKey'; const EMPTY_STATE = { state: 'empty' }; const DEFAULT_DATA = 'random-data'; -const INITIAL_DATA: StorageValue = { data: { body: true }, expiration: Infinity, state: 'cached' }; +const INITIAL_DATA: CachedStorageValue = { + data: { body: true }, + createdAt: Date.now(), + ttl: Infinity, + state: 'cached' +}; describe('Tests update-cache', () => { let axios: AxiosCacheInstance; @@ -42,7 +47,8 @@ describe('Tests update-cache', () => { await updateCache(axios, DEFAULT_DATA, { [KEY]: (cached, newData) => ({ state: 'cached', - expiration: Infinity, + ttl: Infinity, + createdAt: Date.now(), data: { body: `${cached.data?.body}:${newData}` } }) }); @@ -53,6 +59,6 @@ describe('Tests update-cache', () => { expect(response).not.toStrictEqual(EMPTY_STATE); expect(response.state).toBe('cached'); - expect(response.data?.body).toBe(`${INITIAL_DATA.data.body}:${DEFAULT_DATA}`); + expect(response.data?.body).toBe(`${INITIAL_DATA.data?.body}:${DEFAULT_DATA}`); }); });