svelte-ux
Version:
- Increment version in `package.json` and commit as `Version bump to x.y.z` - `npm run publish`
233 lines (232 loc) • 10.7 kB
JavaScript
import { getContext, setContext } from 'svelte';
import { get, writable } from 'svelte/store';
import { merge } from 'lodash-es';
const CONTEXT_KEY = {};
export function initFetchClient(config) {
setContext(CONTEXT_KEY, config);
}
export const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const DEFAULT_STATE = {
loading: null,
data: undefined,
error: undefined,
request: undefined,
response: undefined,
};
export default function fetchStore() {
const globalConfig = getContext(CONTEXT_KEY);
const localErrors = writable([]);
const { subscribe, set, update } = writable({ ...DEFAULT_STATE }, () => {
return () => {
// Remove errors from global errors when no longer subscribed (component unmounted which uses store instance)
removeGlobalErrors(globalConfig === null || globalConfig === void 0 ? void 0 : globalConfig.errors, localErrors);
};
});
// Track first data load for `once`
let loaded = false;
const promises = [];
const fetchConfigStore = writable({
url: '',
});
function doFetch(url, config) {
var _a, _b, _c, _d, _e, _f;
const mergedConfig = merge({}, globalConfig, config);
const prevFetchConfig = get(fetchConfigStore);
// Save for refreshing or other building derived requests (ex. exports)
fetchConfigStore.set({ url, config: mergedConfig });
if ((mergedConfig === null || mergedConfig === void 0 ? void 0 : mergedConfig.disabled) === true ||
((mergedConfig === null || mergedConfig === void 0 ? void 0 : mergedConfig.once) && loaded && (mergedConfig === null || mergedConfig === void 0 ? void 0 : mergedConfig.force) !== true)) {
// disabled or request already loaded and `once` set (and not forced) - do nothing
}
else if ((mergedConfig === null || mergedConfig === void 0 ? void 0 : mergedConfig.force) !== true &&
url === prevFetchConfig.url &&
((_a = mergedConfig === null || mergedConfig === void 0 ? void 0 : mergedConfig.options) === null || _a === void 0 ? void 0 : _a.call(mergedConfig).body) === ((_c = (_b = prevFetchConfig === null || prevFetchConfig === void 0 ? void 0 : prevFetchConfig.config) === null || _b === void 0 ? void 0 : _b.options) === null || _c === void 0 ? void 0 : _c.call(_b).body) &&
(mergedConfig === null || mergedConfig === void 0 ? void 0 : mergedConfig.disabled) === ((_d = prevFetchConfig === null || prevFetchConfig === void 0 ? void 0 : prevFetchConfig.config) === null || _d === void 0 ? void 0 : _d.disabled)) {
// skip identifical request as last unless force enabled
}
else {
const options = merge({}, defaultOptions, (_e = globalConfig === null || globalConfig === void 0 ? void 0 : globalConfig.options) === null || _e === void 0 ? void 0 : _e.call(globalConfig), (_f = config === null || config === void 0 ? void 0 : config.options) === null || _f === void 0 ? void 0 : _f.call(config));
const request = { url, options };
// Remove local errors from global errors and clear all local errors when loading new request
removeGlobalErrors(globalConfig === null || globalConfig === void 0 ? void 0 : globalConfig.errors, localErrors);
localErrors.set([]);
update((currentState) => doUpdate(currentState, { request, loading: true }, null, mergedConfig));
const as = (mergedConfig === null || mergedConfig === void 0 ? void 0 : mergedConfig.as) || 'auto';
const promise = fetch(url, options)
.then(async (response) => {
const dataPromise = typeof as === 'function'
? as(response)
: typeof as === 'object'
? parseBody(response, as)
: as === 'auto'
? parseBody(response)
: response[as]();
try {
const data = await dataPromise;
const newState = {
request,
loading: false,
data: response.ok ? data : undefined,
error: response.ok ? undefined : data,
response,
};
update((currentState) => doUpdate(currentState, newState, promise, mergedConfig));
loaded = true;
}
catch (error) {
const newState = {
request,
loading: false,
data: undefined,
error: error,
response,
};
update((currentState) => doUpdate(currentState, newState, promise, mergedConfig));
}
})
.catch((error) => {
// Catch request errors with no response (CORS issues, etc)
const newState = {
request,
data: undefined,
error,
loading: false,
};
update((currentState) => doUpdate(currentState, newState, promise, mergedConfig));
// Rethrow so not to swallow errors, especially from errors within handlers (children func / onChange)
throw error;
});
promises.push(promise);
// TODO: more thought...
return { subscribe };
}
}
function doUpdate(currentState, nextState, currentPromise, config) {
if (currentPromise) {
// Handle (i.e. ignore) promises resolved out of order from requests
const index = promises.indexOf(currentPromise);
if (index === -1) {
// Ignore update as a later request/promise has already been processed
// console.log('skipping update');
return currentState;
}
// Remove currently resolved promise and any outstanding promises
// (which will cause them to be ignored when they do resolve/reject)
promises.splice(0, index + 1);
}
let data = undefined;
if (nextState.data && nextState.data !== currentState.data && (config === null || config === void 0 ? void 0 : config.onDataChange)) {
try {
data = config.onDataChange(nextState.data, currentState.data);
}
catch (err) {
console.error(err);
}
}
let newState = {
...currentState,
...nextState,
...(data !== undefined && { data }), // If `onDataChange` returned a value, we use it for data passed down to the children function
};
if (nextState.response &&
nextState.response !== currentState.response &&
(config === null || config === void 0 ? void 0 : config.onResponseChange)) {
try {
data = config.onResponseChange(nextState.response, newState);
}
catch (err) {
console.error(err);
}
}
newState = {
...newState,
...(data !== undefined && { data }), // If `onResponseChange` returned a value, we use it for data passed down to the children function
};
// if (isFunction(onChange)) {
// // Always call onChange even if unmounted. Useful for `POST` requests with a redirect
// onChange({
// ...currentState,
// ...nextState,
// ...(data !== undefined && { data }),
// });
// }
if (newState.error && (config === null || config === void 0 ? void 0 : config.suppressErrors) !== true) {
// Add errors to global `errors` store
addError(globalConfig === null || globalConfig === void 0 ? void 0 : globalConfig.errors, newState.error);
// Track errors specific to this store instance as well to support removal from global errors on unsubscribe (component unmount)
addError(localErrors, newState.error);
}
// console.log({ newState });
return newState;
}
return {
subscribe,
fetch: doFetch,
refresh() {
const { url, config } = get(fetchConfigStore);
doFetch(url, { ...config, force: true });
},
clear() {
const { config } = get(fetchConfigStore);
update((currentState) => doUpdate(currentState, { ...DEFAULT_STATE }, null, config));
},
fetchConfig: fetchConfigStore,
};
}
function addError(store, error) {
if (store) {
store.update((current) => {
const result = [...current];
if (Array.isArray(error)) {
error.forEach((e) => result.push(e));
}
else {
result.push(error);
}
return result;
});
}
}
function removeGlobalErrors(globalErrors, localErrors) {
if (globalErrors) {
const $localErrors = get(localErrors);
globalErrors.update(($errors) => $errors.filter((e) => !$localErrors.includes(e)));
}
}
function parseBody(response, mapping = {}) {
const contentType = response.headers.get('Content-Type');
// Do not attempt to parse empty response
if (contentType === null) {
return Promise.resolve(null);
}
const mimeType = contentType.split(';')[0].trim();
if (mimeType in mapping) {
// Direct mapping of `Content-Type`/`mimeType` to response handler
return mapping[mimeType](response);
}
else if (mimeType === 'application/json' ||
mimeType === 'text/json' ||
/\+json$/.test(mimeType) // ends with "+json"
) {
// https://mimesniff.spec.whatwg.org/#json-mime-type
return 'json' in mapping ? mapping['json'](response) : response.json();
}
else if (mimeType === 'text/html') {
// https://mimesniff.spec.whatwg.org/#html-mime-type
return 'html' in mapping ? mapping['html'](response) : response.text();
}
else if (mimeType === 'application/xml' ||
mimeType === 'text/xml' ||
/\+xml$/.test(mimeType) // ends with "+xml"
) {
// https://mimesniff.spec.whatwg.org/#xml-mime-type
return 'xml' in mapping ? mapping['xml'](response) : response.text();
}
else {
return 'other' in mapping ? mapping['other'](response) : response.arrayBuffer();
}
}