Skip to content

Commit

Permalink
refactor: ttl and createdAt instead of maxAge and storage takes care …
Browse files Browse the repository at this point in the history
…of staled entries
  • Loading branch information
arthurfiorette committed Sep 13, 2021
1 parent b45fd54 commit be5ee1e
Show file tree
Hide file tree
Showing 13 changed files with 152 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/axios/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions src/axios/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
10 changes: 4 additions & 6 deletions src/interceptors/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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') {
Expand Down
7 changes: 4 additions & 3 deletions src/interceptors/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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
Expand Down
16 changes: 9 additions & 7 deletions src/storage/memory.ts
Original file line number Diff line number Diff line change
@@ -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> => {
Expand Down
23 changes: 18 additions & 5 deletions src/storage/types.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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';
};
21 changes: 17 additions & 4 deletions src/storage/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> => {
Expand All @@ -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);
}
}

Expand Down
6 changes: 6 additions & 0 deletions test/storage/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { MemoryStorage } from '../../src/storage';
import { testStorage } from './storages';

describe('tests common storages', () => {
testStorage('memory', MemoryStorage);
});
60 changes: 60 additions & 0 deletions test/storage/storages.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions test/storage/web.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
3 changes: 2 additions & 1 deletion test/util/status-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
});
Expand Down
14 changes: 10 additions & 4 deletions test/util/update-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}` }
})
});
Expand All @@ -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}`);
});
});

0 comments on commit be5ee1e

Please sign in to comment.