refactor: ttl and createdAt instead of maxAge and storage takes care of staled entries

This commit is contained in:
Hazork 2021-09-13 16:05:37 -03:00
parent b45fd54f69
commit be5ee1ea8b
13 changed files with 152 additions and 34 deletions

View File

@ -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",

View File

@ -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,

View File

@ -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
*/

View File

@ -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') {

View File

@ -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

View File

@ -1,19 +1,21 @@
import { EmptyStorageValue } from '.';
import { CacheStorage, StorageValue } from './types';
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> => {
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<void> => {

View File

@ -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<StorageValue>;
/**
* 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<void>;
/**
* 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';
};

View File

@ -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<StorageValue> => {
const json = this.storage.getItem(this.prefix + key);
return json ? JSON.parse(json) : { state: 'empty' };
get = async (_key: string): Promise<StorageValue> => {
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<void> => {
@ -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);
}
}

View 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
View 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
View 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);
});

View File

@ -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'
});
});

View File

@ -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}`);
});
});