Skip to content

Commit

Permalink
refactor: save all deferred to cache.waiting instead of in the storage
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurfiorette committed Sep 10, 2021
1 parent 99f69ce commit ab051fc
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 59 deletions.
2 changes: 1 addition & 1 deletion src/axios/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from './cache';
export * from './types'
export * from './types';
24 changes: 15 additions & 9 deletions src/axios/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import type {
AxiosResponse,
Method
} from 'axios';
import { CacheStorage } from '../storage/types';
import { Deferred } from 'src/utils/deferred';
import { CachedResponse, CacheStorage } from '../storage/types';

export type DefaultCacheRequestConfig = AxiosRequestConfig & {
cache: Required<CacheProperties>;
cache: CacheProperties;
};

export type CacheProperties = {
Expand All @@ -18,29 +19,29 @@ export type CacheProperties = {
*
* @default 1000 * 60 * 5
*/
maxAge?: number;
maxAge: number;

/**
* If this interceptor should configure the cache from the request cache header
* When used, the maxAge property is ignored
*
* @default false
*/
interpretHeader?: boolean;
interpretHeader: boolean;

/**
* All methods that should be cached.
*
* @default ['get']
*/
methods?: Lowercase<Method>[];
methods: Lowercase<Method>[];

/**
* The function to check if the response code permit being cached.
*
* @default ({ status }) => status >= 200 && status < 300
*/
shouldCache?: (response: AxiosResponse) => boolean;
shouldCache: (response: AxiosResponse) => boolean;

/**
* Once the request is resolved, this specifies what requests should we change the cache.
Expand All @@ -54,8 +55,8 @@ export type CacheProperties = {
*
* @default {}
*/
update?: {
[id: string]: 'delete' | ((oldValue: any, atual: any) => any | void);
update: {
[id: string]: 'delete' | ((oldValue: any, atual: any) => any | undefined);
};
};

Expand All @@ -74,7 +75,7 @@ export type CacheRequestConfig = AxiosRequestConfig & {
/**
* All cache options for the request
*/
cache?: CacheProperties;
cache?: Partial<CacheProperties>;
};

export interface CacheInstance {
Expand All @@ -91,6 +92,11 @@ export interface CacheInstance {
* a string is generated using the method, baseUrl, params, and url
*/
generateKey: (options: CacheRequestConfig) => string;

/**
* A simple object that holds all deferred objects until it is resolved.
*/
waiting: Record<string, Deferred<CachedResponse>>;
}

/**
Expand Down
26 changes: 22 additions & 4 deletions src/interceptors/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CachedResponse } from 'src/storage/types';
import { AxiosCacheInstance } from '../axios/types';
import { CACHED_RESPONSE_STATUS, CACHED_RESPONSE_STATUS_TEXT } from '../constants';
import { Deferred } from '../utils/deferred';
Expand All @@ -14,14 +15,18 @@ export function applyRequestInterceptor(axios: AxiosCacheInstance) {

// Not cached, continue the request, and mark it as fetching
if (cache.state == 'empty') {
// Create a deferred to resolve other requests for the same key when it's completed
axios.waiting[key] = new Deferred();

await axios.storage.set(key, {
state: 'loading',
data: new Deferred(),
data: null,
// The cache header will be set after the response has been read, until that time, the expiration will be -1
expiration: config.cache?.interpretHeader
? -1
: config.cache?.maxAge || axios.defaults.cache.maxAge
});

return config;
}

Expand All @@ -31,13 +36,26 @@ export function applyRequestInterceptor(axios: AxiosCacheInstance) {
return config;
}

const { body, headers } = await cache.data;
let data = {} as CachedResponse;
if (cache.state === 'loading') {
const deferred = axios.waiting[key];

// If the deferred is undefined, means that the
// outside has removed that key from the waiting list
if (!deferred) {
return config;
}

data = await deferred;
} else {
data = cache.data;
}

config.adapter = () =>
Promise.resolve({
data: body,
config,
headers,
data: data.body,
headers: data.headers,
status: CACHED_RESPONSE_STATUS,
statusText: CACHED_RESPONSE_STATUS_TEXT
});
Expand Down
70 changes: 28 additions & 42 deletions src/interceptors/response.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,12 @@
import { parse } from '@tusbar/cache-control';
import { AxiosCacheInstance } from '../axios/types';
import { interpretCacheHeader } from './util/interpret-header';
import { updateCache } from './util/update-cache';

export function applyResponseInterceptor(axios: AxiosCacheInstance) {
axios.interceptors.response.use(async (response) => {
// Update other entries before updating himself
for (const [cacheKey, value] of Object.entries(response.config.cache?.update || {})) {
if (value == 'delete') {
await axios.storage.remove(cacheKey);
continue;
}

const oldValue = await axios.storage.get(cacheKey);
const newValue = value(oldValue, response.data);
if (newValue !== undefined) {
await axios.storage.set(cacheKey, newValue);
} else {
await axios.storage.remove(cacheKey);
}
if (response.config.cache?.update) {
updateCache(axios, response.data, response.config.cache.update);
}

// Config told that this response should be cached.
Expand All @@ -27,46 +17,42 @@ export function applyResponseInterceptor(axios: AxiosCacheInstance) {
const key = axios.generateKey(response.config);
const cache = await axios.storage.get(key);

if (
// Response already is in cache.
cache.state === 'cached' ||
// Received response without being intercepted in the response
cache.state === 'empty'
) {
// Response already is in cache or received without
// being intercepted in the response
if (cache.state === 'cached' || cache.state === 'empty') {
return response;
}

const defaultMaxAge = response.config.cache?.maxAge || axios.defaults.cache.maxAge;
cache.expiration = cache.expiration || defaultMaxAge;
let shouldCache = true;

if (response.config.cache?.interpretHeader) {
const cacheControl = response.headers['cache-control'] || '';
const { noCache, noStore, maxAge } = parse(cacheControl);
const expirationTime = interpretCacheHeader(response.headers['cache-control']);

// Header told that this response should not be cached.
if (noCache || noStore) {
return response;
if (expirationTime === false) {
shouldCache = false;
} else {
cache.expiration = expirationTime ? expirationTime : defaultMaxAge;
}

const expirationTime = maxAge
? // Header max age in seconds
Date.now() + maxAge * 1000
: response.config.cache?.maxAge || axios.defaults.cache.maxAge;

cache.expiration = expirationTime;
} else {
// If the cache expiration has not been set, use the default expiration.
cache.expiration =
cache.expiration || response.config.cache?.maxAge || axios.defaults.cache.maxAge!;
}

const data = { body: response.data, headers: response.headers };
const deferred = axios.waiting[key];

// Resolve this deferred to update the cache after it
cache.data.resolve(data);
// Resolve all other requests waiting for this response
if (deferred) {
deferred.resolve(data);
}

await axios.storage.set(key, {
data,
expiration: cache.expiration,
state: 'cached'
});
if (shouldCache) {
await axios.storage.set(key, {
data,
expiration: cache.expiration,
state: 'cached'
});
}

return response;
});
Expand Down
28 changes: 28 additions & 0 deletions src/interceptors/util/interpret-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { parse } from '@tusbar/cache-control';
import { AxiosResponse } from 'axios';

// response.config.cache?.maxAge || axios.defaults.cache.maxAge

/**
* Interpret the cache control header, if present.
*
* @param header the header to interpret.
* @returns false if header is not valid, undefined if the maxAge was not specified or a number in seconds from now.
*/
export function interpretCacheHeader(
headers: AxiosResponse['headers']
): false | undefined | number {
const cacheControl = headers['cache-control'] || '';
const { noCache, noStore, maxAge } = parse(cacheControl);

// Header told that this response should not be cached.
if (noCache || noStore) {
return false;
}

if (!maxAge) {
return undefined;
}

return Date.now() + maxAge * 1000;
}
24 changes: 24 additions & 0 deletions src/interceptors/util/update-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AxiosCacheInstance, CacheProperties } from '../../axios';

export async function updateCache(
axios: AxiosCacheInstance,
data: any,
entries: CacheProperties['update']
) {
for (const [cacheKey, value] of Object.entries(entries)) {
if (value == 'delete') {
await axios.storage.remove(cacheKey);
continue;
}

const oldValue = await axios.storage.get(cacheKey);
const newValue = value(oldValue, data);

if (newValue === undefined) {
await axios.storage.remove(cacheKey);
continue;
}

await axios.storage.set(cacheKey, newValue);
}
}
4 changes: 1 addition & 3 deletions src/storage/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Deferred } from '../utils/deferred';

export interface CacheStorage {
/**
* Returns the cached value for the given key. Should return a 'empty'
Expand Down Expand Up @@ -31,7 +29,7 @@ export type StorageValue =
state: 'cached';
}
| {
data: Deferred<CachedResponse>;
data: null;
/**
* If interpretHeader is used, this value will be `-1`until the response is received
*/
Expand Down

0 comments on commit ab051fc

Please sign in to comment.