Skip to content

Commit

Permalink
fetch: first cache draft
Browse files Browse the repository at this point in the history
  • Loading branch information
Alex Lohr committed May 24, 2022
1 parent 64a7bda commit 3f1184b
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 117 deletions.
2 changes: 1 addition & 1 deletion packages/fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"tslib": "^2.3.1",
"tsup": "^5.10.1",
"typescript": "^4.5.2",
"uvu": "^0.5.2",
"uvu": "^0.5.3",
"vite": "^2.9.9",
"vite-plugin-solid": "2.2.6"
},
Expand Down
76 changes: 76 additions & 0 deletions packages/fetch/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { DEV } from "solid-js";
import { RequestContext } from "./fetch";
import { RequestModifier, wrapFetcher } from "./modifiers";

export type CacheEntry = {
ts: number;
requestData: [info: RequestInfo, init?: RequestInit];
data: any;
};

export type CacheOptions = {
expires: number | ((entry: CacheEntry) => boolean);
};

export const defaultCacheOptions = {
expires: 5000
};

export type RequestCache = Record<string, Response>;

export const serializeRequest = (requestData: [info: RequestInfo, init?: RequestInit]): string =>
JSON.stringify({
...(typeof requestData[0] === "string" ? { url: requestData[0] } : requestData[0]),
...requestData[1]
});

export const withCache: RequestModifier =
<T>(options = defaultCacheOptions) =>
(requestContext: RequestContext<T>) => {
requestContext.cache = {} as RequestCache;
const isExpired =
typeof options.expires === "number"
? (entry: CacheEntry) => entry.ts + options.expires > new Date().getTime()
: options.expires;
wrapFetcher<T>(requestContext, <T>(originalFetcher: any) => (requestData, info) => {
const serializedRequest = serializeRequest(requestData);
const cached: CacheEntry | undefined = requestContext.cache[serializedRequest];
const shouldRead = requestContext.readCache?.(cached) !== false;
if (cached && !isExpired(cached) && shouldRead) {
return Promise.resolve<T>(cached.data);
}
return originalFetcher(requestData, info).then((data: T) => {
requestContext.writeCache?.(
serializedRequest,
(requestContext.cache[serializedRequest] = {
ts: new Date().getTime(),
requestData: requestData,
data
})
);
return data;
});
});
requestContext.wrapResource();
};

export const withCacheStorage: RequestModifier =
(storage: Storage = localStorage, key = "cache") =>
requestContext => {
try {
const loadedCache = JSON.parse(storage.getItem(key) || "{}");
requestContext.cache = loadedCache;
} catch (e) {
DEV && console.warn("attempt to parse stored request cache failed with error", e);
}
const originalWriteCache = requestContext.writeCache;
requestContext.writeCache = (...args: any[]) => {
originalWriteCache?.(...args);
try {
localStorage.setItem(key, JSON.stringify(requestContext.cache));
} catch (e) {
DEV && console.warn("attempt to store request cache failed with error", e);
}
};
requestContext.wrapResource();
};
6 changes: 3 additions & 3 deletions packages/fetch/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ export type FetchOptions<I> = I extends undefined
initialValue?: I;
name?: string;
fetch?: typeof fetch;
request: <T>(requestContext: RequestContext<T>) => void;
request?: <T>(requestContext: RequestContext<T>) => void;
responseHandler?: (response: Response) => any;
disable?: boolean;
}
: {
initialValue: I;
name?: string;
fetch?: typeof fetch;
request: <T>(requestContext: RequestContext<T>) => void;
request?: <T>(requestContext: RequestContext<T>) => void;
responseHandler?: (response: Response) => any;
disable?: boolean;
};
Expand All @@ -60,7 +60,7 @@ export type FetchReturn<T, I> = [
];

const isOptions = <I>(prop: any): prop is FetchOptions<I> =>
typeof prop === "object" && ["name", "initialValue", "fetch", "request"].some((key) => key in prop);
typeof prop === "object" && ["name", "initialValue", "fetch", "request"].some(key => key in prop);

/* we want to be able to overload our functions */
/* eslint no-redeclare:off */
Expand Down
181 changes: 92 additions & 89 deletions packages/fetch/src/modifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { RequestContext } from "./fetch";

export type RequestModifier = <T>(...args: any[]) => (requestContext: RequestContext<T>) => any;

export type Fetcher<T> = Exclude<RequestContext<T>['fetcher'], undefined>;
export type Fetcher<T> = Exclude<RequestContext<T>["fetcher"], undefined>;

export const wrapFetcher = <T>(
requestContext: RequestContext<T>,
wrapper: (originalFetcher: Fetcher<T>) => Fetcher<T>
) => {
const originalFetcher = requestContext.fetcher
const originalFetcher = requestContext.fetcher;
if (!originalFetcher) {
throw new Error("could not read resource fetcher");
}
Expand All @@ -18,10 +18,9 @@ export const wrapFetcher = <T>(

export const wrapResource = <T>(
requestContext: RequestContext<T>,
wrapper: (requestContext: RequestContext<T>) => [
props?: { [key: string]: any },
actions?: { [key: string]: any }
]
wrapper: (
requestContext: RequestContext<T>
) => [props?: { [key: string]: any }, actions?: { [key: string]: any }]
) => {
if (!requestContext.resource) {
throw new Error("could not read resource");
Expand All @@ -34,113 +33,117 @@ export const wrapResource = <T>(
export const withAbort: RequestModifier =
<T>() =>
requestContext => {
wrapFetcher(requestContext, (originalFetcher) => <T>(
requestData: [info: RequestInfo, init?: RequestInit],
info: ResourceFetcherInfo<T>
) => {
if (requestContext.abortController) {
requestContext.abortController.abort();
}
requestContext.abortController = new AbortController();
return originalFetcher(
[requestData[0], { ...requestData[1], signal: requestContext.abortController.signal }],
info
).catch(err => {
if (info.value && err.name === "AbortError") {
return Promise.resolve(info.value);
wrapFetcher(
requestContext,
originalFetcher =>
<T>(requestData: [info: RequestInfo, init?: RequestInit], info: ResourceFetcherInfo<T>) => {
if (requestContext.abortController) {
requestContext.abortController.abort();
}
requestContext.abortController = new AbortController();
return originalFetcher(
[requestData[0], { ...requestData[1], signal: requestContext.abortController.signal }],
info
).catch(err => {
if (info.value && err.name === "AbortError") {
return Promise.resolve(info.value);
}
throw err;
});
}
throw err;
});
});
);
requestContext.wrapResource();
wrapResource(requestContext, (requestContext) => [{
aborted: { get: () => requestContext.abortController?.signal.aborted || false },
status: { get: () => requestContext.response?.status },
response: { get: () => requestContext.response }
}, {
abort: () => requestContext.abortController?.abort()
}]);
wrapResource(requestContext, requestContext => [
{
aborted: { get: () => requestContext.abortController?.signal.aborted || false },
status: { get: () => requestContext.response?.status },
response: { get: () => requestContext.response }
},
{
abort: () => requestContext.abortController?.abort()
}
]);
};

export const withTimeout: RequestModifier =
<T>(timeout: number) =>
requestContext => {
wrapFetcher(requestContext, (originalFetcher) => (requestData, info) =>
new Promise((resolve, reject) => {
window.setTimeout(() => {
requestContext.abortController?.abort("timeout");
reject(new Error("timeout"));
}, timeout);
originalFetcher(requestData, info).then(resolve as any).catch(reject);
}));
wrapFetcher(
requestContext,
originalFetcher => (requestData, info) =>
new Promise((resolve, reject) => {
window.setTimeout(() => {
requestContext.abortController?.abort("timeout");
reject(new Error("timeout"));
}, timeout);
originalFetcher(requestData, info)
.then(resolve as any)
.catch(reject);
})
);
requestContext.wrapResource();
}
};

export const withCatchAll: RequestModifier =
<T>() =>
requestContext => {
const [error, setError] = createSignal<Error | undefined>();
wrapFetcher(requestContext, (originalFetcher) => (requestData, info) =>
originalFetcher(requestData, info).catch(err => {
setError(err);
return Promise.resolve(info.value!);
}));
wrapFetcher(
requestContext,
originalFetcher => (requestData, info) =>
originalFetcher(requestData, info).catch(err => {
setError(err);
return Promise.resolve(info.value!);
})
);
requestContext.wrapResource();
wrapResource(requestContext, () => [{ error: { get: () => error() } }, undefined]);
};

export type CacheOptions = {
/** put the cached data into a storage, e.g. localStorage */
storage?: Storage,
/** put the cached data into a certain key in the storage; only works if a storage is specified */
storageKey?: string,

};

/**
* Caches requests
* @param options
* @returns
*/
export const withCache: RequestModifier = <T>(options: CacheOptions) => (requestContext) => {
// TODO
}

const defaultWait = (attempt: number) => Math.max(1000 << attempt, 30000);

/**
* Retries the request if it failed
*
*
* @param retries the amount of times the request should be retried if it fails.
* @param wait the time it should wait as a number of ms or a function that receives the
* @param wait the time it should wait as a number of ms or a function that receives the
* number of the current attempt and returns a number; default is 1000 doubled on every
* retry, but never more than 30000.
* @param verify a function that receives the response and verifies if it is valid;
* default checks if response.ok is true.
*/
export const withRetry: RequestModifier = <T>(
retries: number,
wait: number | ((attempt: number) => number) = defaultWait,
verify = (response?: Response) => response?.ok
) => (requestContext) => {
const waitForAttempt = (attempt: number) => new Promise<void>((resolve) =>
setTimeout(resolve, typeof wait === 'number' ? wait : wait(attempt))
);
wrapFetcher(requestContext, (originalFetcher) =>
<T>(
requestData: [info: RequestInfo, init?: RequestInit | undefined],
info: ResourceFetcherInfo<T>
) => {
const wrappedFetcher = (attempt: number): Promise<T> => originalFetcher<T>(requestData, info)
.then((data: T) => !verify(requestContext.response) && attempt <= retries
? waitForAttempt(attempt).then(() => wrappedFetcher(attempt + 1))
: data
)
.catch((err) => attempt > retries
? Promise.reject(err)
: waitForAttempt(attempt).then(() => wrappedFetcher(attempt + 1))
);
return wrappedFetcher(0);
})
requestContext.wrapResource();
};
export const withRetry: RequestModifier =
<T>(
retries: number,
wait: number | ((attempt: number) => number) = defaultWait,
verify = (response?: Response) => response?.ok
) =>
requestContext => {
const waitForAttempt = (attempt: number) =>
new Promise<void>(resolve =>
setTimeout(resolve, typeof wait === "number" ? wait : wait(attempt))
);
wrapFetcher(
requestContext,
originalFetcher =>
<T>(
requestData: [info: RequestInfo, init?: RequestInit | undefined],
info: ResourceFetcherInfo<T>
) => {
const wrappedFetcher = (attempt: number): Promise<T> =>
originalFetcher<T>(requestData, info)
.then((data: T) =>
!verify(requestContext.response) && attempt <= retries
? waitForAttempt(attempt).then(() => wrappedFetcher(attempt + 1))
: data
)
.catch(err =>
attempt > retries
? Promise.reject(err)
: waitForAttempt(attempt).then(() => wrappedFetcher(attempt + 1))
);
return wrappedFetcher(0);
}
);
requestContext.wrapResource();
};
27 changes: 12 additions & 15 deletions packages/fetch/src/request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RequestModifier } from "./modifiers";

import { AxiosStatic } from 'axios';
import { AxiosStatic } from "axios";

export const fetchRequest: RequestModifier =
<T>(fetch = globalThis.fetch) =>
Expand All @@ -23,18 +23,15 @@ export const fetchRequest: RequestModifier =
requestContext.wrapResource();
};

export const axiosRequest: RequestModifier = (axios: AxiosStatic) =>
requestContext => {
requestContext.fetcher = (requestData: [info: RequestInfo, init?: RequestInit]) => {
return axios.request({
...(typeof requestData[0] === 'string'
? { url: requestData[0], method: 'get' }
: requestData[0]),
...requestData[1],
...(requestContext.abortController
? { signal: requestContext.abortController.signal }
: {})
} as any)
}
requestContext.wrapResource();
export const axiosRequest: RequestModifier = (axios: AxiosStatic) => requestContext => {
requestContext.fetcher = (requestData: [info: RequestInfo, init?: RequestInit]) => {
return axios.request({
...(typeof requestData[0] === "string"
? { url: requestData[0], method: "get" }
: requestData[0]),
...requestData[1],
...(requestContext.abortController ? { signal: requestContext.abortController.signal } : {})
} as any);
};
requestContext.wrapResource();
};
Loading

0 comments on commit 3f1184b

Please sign in to comment.