mirror of
https://github.com/arthurfiorette/axios-cache-interceptor.git
synced 2025-12-08 17:36:16 +00:00
refactor: ttl and createdAt instead of maxAge and storage takes care of staled entries
This commit is contained in:
parent
b45fd54f69
commit
be5ee1ea8b
@ -9,6 +9,11 @@
|
|||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": ["@typescript-eslint"],
|
"plugins": ["@typescript-eslint"],
|
||||||
"root": true,
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"amd": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function createCache(
|
|||||||
axiosCache.defaults = {
|
axiosCache.defaults = {
|
||||||
...axios.defaults,
|
...axios.defaults,
|
||||||
cache: {
|
cache: {
|
||||||
maxAge: 1000 * 60 * 5,
|
ttl: 1000 * 60 * 5,
|
||||||
interpretHeader: false,
|
interpretHeader: false,
|
||||||
methods: ['get'],
|
methods: ['get'],
|
||||||
cachePredicate: ({ status }) => status >= 200 && status < 300,
|
cachePredicate: ({ status }) => status >= 200 && status < 300,
|
||||||
|
|||||||
@ -25,13 +25,15 @@ export type CacheProperties = {
|
|||||||
/**
|
/**
|
||||||
* The time until the cached value is expired in milliseconds.
|
* 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
|
* 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
|
* @default false
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export function applyRequestInterceptor(axios: AxiosCacheInstance): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = axios.generateKey(config);
|
const key = axios.generateKey(config);
|
||||||
|
|
||||||
|
// Assumes that the storage handled staled responses
|
||||||
const cache = await axios.storage.get(key);
|
const cache = await axios.storage.get(key);
|
||||||
|
|
||||||
// Not cached, continue the request, and mark it as fetching
|
// 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();
|
axios.waiting[key] = new Deferred();
|
||||||
|
|
||||||
await axios.storage.set(key, {
|
await axios.storage.set(key, {
|
||||||
state: 'loading'
|
state: 'loading',
|
||||||
|
ttl: config.cache?.ttl
|
||||||
});
|
});
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cache.state === 'cached' && cache.expiration < Date.now()) {
|
|
||||||
await axios.storage.remove(key);
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: CachedResponse = {};
|
let data: CachedResponse = {};
|
||||||
|
|
||||||
if (cache.state === 'loading') {
|
if (cache.state === 'loading') {
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function applyResponseInterceptor(axios: AxiosCacheInstance): void {
|
|||||||
return response;
|
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) {
|
if (response.config.cache?.interpretHeader) {
|
||||||
const expirationTime = axios.headerInterpreter(response.headers['cache-control']);
|
const expirationTime = axios.headerInterpreter(response.headers['cache-control']);
|
||||||
@ -42,13 +42,14 @@ export function applyResponseInterceptor(axios: AxiosCacheInstance): void {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
expiration = expirationTime ? expirationTime : expiration;
|
ttl = expirationTime ? expirationTime : ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCache: CachedStorageValue = {
|
const newCache: CachedStorageValue = {
|
||||||
data: { body: response.data, headers: response.headers },
|
data: { body: response.data, headers: response.headers },
|
||||||
state: 'cached',
|
state: 'cached',
|
||||||
expiration: expiration
|
ttl: ttl,
|
||||||
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update other entries before updating himself
|
// Update other entries before updating himself
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
import { EmptyStorageValue } from '.';
|
|
||||||
import { CacheStorage, StorageValue } from './types';
|
import { CacheStorage, StorageValue } from './types';
|
||||||
|
|
||||||
export class MemoryStorage implements CacheStorage {
|
export class MemoryStorage implements CacheStorage {
|
||||||
readonly storage: Map<string, StorageValue> = new Map();
|
private readonly storage: Map<string, StorageValue> = new Map();
|
||||||
|
|
||||||
get = async (key: string): Promise<StorageValue> => {
|
get = async (key: string): Promise<StorageValue> => {
|
||||||
const value = this.storage.get(key);
|
const value = this.storage.get(key);
|
||||||
|
|
||||||
if (value) {
|
if (!value) {
|
||||||
return value;
|
return { state: 'empty' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty: EmptyStorageValue = { state: 'empty' };
|
if (value.state === 'cached' && value.createdAt + value.ttl < Date.now()) {
|
||||||
this.storage.set(key, empty);
|
this.remove(key);
|
||||||
return empty;
|
return { state: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
set = async (key: string, value: StorageValue): Promise<void> => {
|
set = async (key: string, value: StorageValue): Promise<void> => {
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
export interface CacheStorage {
|
export interface CacheStorage {
|
||||||
/**
|
/**
|
||||||
* Returns the cached value for the given key. Should return a 'empty'
|
* Returns the cached value for the given key.
|
||||||
* state StorageValue if the key does not exist.
|
* Must handle cache miss and staling by returning a new `StorageValue` with `empty` state.
|
||||||
*/
|
*/
|
||||||
get: (key: string) => Promise<StorageValue>;
|
get: (key: string) => Promise<StorageValue>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a new value for the given key
|
* Sets a new value for the given key
|
||||||
*
|
*
|
||||||
* Use CacheStorage.remove(key) to define a key to 'empty' state.
|
* Use CacheStorage.remove(key) to define a key to 'empty' state.
|
||||||
*/
|
*/
|
||||||
set: (key: string, value: LoadingStorageValue | CachedStorageValue) => Promise<void>;
|
set: (key: string, value: LoadingStorageValue | CachedStorageValue) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the value for the given key
|
* Removes the value for the given key
|
||||||
*/
|
*/
|
||||||
@ -28,18 +30,29 @@ export type StorageValue = CachedStorageValue | LoadingStorageValue | EmptyStora
|
|||||||
|
|
||||||
export type CachedStorageValue = {
|
export type CachedStorageValue = {
|
||||||
data: CachedResponse;
|
data: CachedResponse;
|
||||||
expiration: number;
|
ttl: number;
|
||||||
|
createdAt: number;
|
||||||
state: 'cached';
|
state: 'cached';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoadingStorageValue = {
|
export type LoadingStorageValue = {
|
||||||
data?: undefined;
|
data?: undefined;
|
||||||
expiration?: undefined;
|
ttl?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defined when the state is cached
|
||||||
|
*/
|
||||||
|
createdAt?: undefined;
|
||||||
state: 'loading';
|
state: 'loading';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmptyStorageValue = {
|
export type EmptyStorageValue = {
|
||||||
data?: undefined;
|
data?: undefined;
|
||||||
expiration?: undefined;
|
ttl?: undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defined when the state is cached
|
||||||
|
*/
|
||||||
|
createdAt?: undefined;
|
||||||
state: 'empty';
|
state: 'empty';
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,9 +5,22 @@ import { CacheStorage, StorageValue } from './types';
|
|||||||
export abstract class WindowStorageWrapper implements CacheStorage {
|
export abstract class WindowStorageWrapper implements CacheStorage {
|
||||||
constructor(readonly storage: Storage, readonly prefix: string = 'axios-cache:') {}
|
constructor(readonly storage: Storage, readonly prefix: string = 'axios-cache:') {}
|
||||||
|
|
||||||
get = async (key: string): Promise<StorageValue> => {
|
get = async (_key: string): Promise<StorageValue> => {
|
||||||
const json = this.storage.getItem(this.prefix + key);
|
const key = this.prefix + _key;
|
||||||
return json ? JSON.parse(json) : { state: 'empty' };
|
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<void> => {
|
set = async (key: string, value: StorageValue): Promise<void> => {
|
||||||
@ -22,7 +35,7 @@ export abstract class WindowStorageWrapper implements CacheStorage {
|
|||||||
|
|
||||||
export class LocalCacheStorage extends WindowStorageWrapper {
|
export class LocalCacheStorage extends WindowStorageWrapper {
|
||||||
constructor(prefix?: string) {
|
constructor(prefix?: string) {
|
||||||
super(window.localStorage, prefix);
|
super(window.localStorage || localStorage, prefix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
test/storage/common.test.ts
Normal file
6
test/storage/common.test.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { MemoryStorage } from '../../src/storage';
|
||||||
|
import { testStorage } from './storages';
|
||||||
|
|
||||||
|
describe('tests common storages', () => {
|
||||||
|
testStorage('memory', MemoryStorage);
|
||||||
|
});
|
||||||
60
test/storage/storages.ts
Normal file
60
test/storage/storages.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
11
test/storage/web.test.ts
Normal file
11
test/storage/web.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
@ -12,7 +12,8 @@ describe('Tests cached status code', () => {
|
|||||||
|
|
||||||
axios.storage.set(KEY, {
|
axios.storage.set(KEY, {
|
||||||
data: { body: true },
|
data: { body: true },
|
||||||
expiration: Infinity,
|
ttl: Infinity,
|
||||||
|
createdAt: Date.now(),
|
||||||
state: 'cached'
|
state: 'cached'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import { AxiosCacheInstance, StorageValue } from '../../src';
|
import { AxiosCacheInstance, CachedStorageValue } from '../../src';
|
||||||
import { updateCache } from '../../src/util/update-cache';
|
import { updateCache } from '../../src/util/update-cache';
|
||||||
import { mockAxios } from '../mocks/axios';
|
import { mockAxios } from '../mocks/axios';
|
||||||
|
|
||||||
const KEY = 'cacheKey';
|
const KEY = 'cacheKey';
|
||||||
const EMPTY_STATE = { state: 'empty' };
|
const EMPTY_STATE = { state: 'empty' };
|
||||||
const DEFAULT_DATA = 'random-data';
|
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', () => {
|
describe('Tests update-cache', () => {
|
||||||
let axios: AxiosCacheInstance;
|
let axios: AxiosCacheInstance;
|
||||||
@ -42,7 +47,8 @@ describe('Tests update-cache', () => {
|
|||||||
await updateCache(axios, DEFAULT_DATA, {
|
await updateCache(axios, DEFAULT_DATA, {
|
||||||
[KEY]: (cached, newData) => ({
|
[KEY]: (cached, newData) => ({
|
||||||
state: 'cached',
|
state: 'cached',
|
||||||
expiration: Infinity,
|
ttl: Infinity,
|
||||||
|
createdAt: Date.now(),
|
||||||
data: { body: `${cached.data?.body}:${newData}` }
|
data: { body: `${cached.data?.body}:${newData}` }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@ -53,6 +59,6 @@ describe('Tests update-cache', () => {
|
|||||||
expect(response).not.toStrictEqual(EMPTY_STATE);
|
expect(response).not.toStrictEqual(EMPTY_STATE);
|
||||||
|
|
||||||
expect(response.state).toBe('cached');
|
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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user