@data-client/core
Version:
Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch
1,263 lines (1,179 loc) • 39.2 kB
JavaScript
'use strict';
var normalizr = require('@data-client/normalizr');
function expireReducer(state, action) {
const meta = {
...state.meta
};
Object.keys(meta).forEach(key => {
if (action.testKey(key)) {
meta[key] = {
...meta[key],
// 1 instead of 0 so we can do 'falsy' checks to see if it is set
expiresAt: 1
};
}
});
return {
...state,
meta
};
}
function createMeta(expiryLength, fetchedAt) {
const now = Date.now();
return {
fetchedAt: fetchedAt != null ? fetchedAt : now,
date: now,
expiresAt: now + expiryLength
};
}
const FETCH = 'rdc/fetch';
const SET = 'rdc/set';
const SET_RESPONSE = 'rdc/setresponse';
const OPTIMISTIC = 'rdc/optimistic';
const RESET = 'rdc/reset';
const SUBSCRIBE = 'rdc/subscribe';
const UNSUBSCRIBE = 'rdc/unsubscribe';
const INVALIDATE = 'rdc/invalidate';
const INVALIDATEALL = 'rdc/invalidateall';
const EXPIREALL = 'rdc/expireall';
const GC = 'rdc/gc';
const FETCH_TYPE = FETCH;
const ensurePojo =
// FormData doesn't exist in node
/* istanbul ignore else we don't run coverage when we test node*/
typeof FormData !== 'undefined' ? body => body instanceof FormData ? Object.fromEntries(body.entries()) : body : /* istanbul ignore next */body => body;
function createOptimistic(endpoint, args, fetchedAt) {
var _endpoint$dataExpiryL, _endpoint$dataExpiryL2;
/* istanbul ignore next */
if (process.env.NODE_ENV === 'development' && ((_endpoint$dataExpiryL = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL : 0) < 0) {
throw new Error('Negative expiry length are not allowed.');
}
return {
type: OPTIMISTIC,
key: endpoint.key(...args),
args: args.map(ensurePojo),
endpoint,
meta: createMeta((_endpoint$dataExpiryL2 = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL2 : 60000, fetchedAt)
};
}
function fetchReducer(state, action) {
if (action.endpoint.getOptimisticResponse && action.endpoint.sideEffect) {
const setAction = createOptimistic(action.endpoint, action.args, action.meta.fetchedAt);
return {
...state,
optimistic: [...state.optimistic, setAction]
};
}
return state;
}
function invalidateReducer(state, action) {
const endpoints = {
...state.endpoints
};
const meta = {
...state.meta
};
const invalidateKey = key => {
delete endpoints[key];
const itemMeta = {
...meta[key],
expiresAt: 0,
invalidated: true
};
delete itemMeta.error;
meta[key] = itemMeta;
};
if (action.type === INVALIDATE) {
invalidateKey(action.key);
} else {
Object.keys(endpoints).forEach(key => {
if (action.testKey(key)) {
invalidateKey(key);
}
});
}
return {
...state,
endpoints,
meta
};
}
function setReducer(state, action, controller) {
let value;
if (typeof action.value === 'function') {
const previousValue = controller.get(action.schema, ...action.args, state);
if (previousValue === undefined) return state;
value = action.value(previousValue);
} else {
value = action.value;
}
try {
const {
entities,
indexes,
entitiesMeta
} = normalizr.normalize(action.schema, value, action.args, state, action.meta);
return {
entities,
endpoints: state.endpoints,
indexes,
meta: state.meta,
entitiesMeta,
optimistic: state.optimistic,
lastReset: state.lastReset
};
// reducer must update the state, so in case of processing errors we simply compute the endpoints inline
} catch (error) {
// this is not always bubbled up, so let's double sure this doesn't fail silently
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
console.error(error);
}
return state;
}
}
class AbortOptimistic extends Error {}
function setResponseReducer(state, action, controller) {
if (action.error) {
return reduceError(state, action, action.response);
}
try {
var _state$meta$action$ke;
let response;
// for true set's response is contained in action
if (action.type === OPTIMISTIC) {
// this should never happen
/* istanbul ignore if */
if (!action.endpoint.getOptimisticResponse) return state;
try {
// compute optimistic response based on current state
response = action.endpoint.getOptimisticResponse.call(action.endpoint, controller.snapshot(state, action.meta.fetchedAt), ...action.args);
} catch (e) {
// AbortOptimistic means 'do nothing', otherwise we count the exception as endpoint failure
if (e.constructor === AbortOptimistic) {
return state;
}
throw e;
}
} else {
response = action.response;
}
const {
result,
entities,
indexes,
entitiesMeta
} = normalizr.normalize(action.endpoint.schema, response, action.args, state, action.meta);
const endpoints = {
...state.endpoints,
[action.key]: result
};
try {
if (action.endpoint.update) {
const updaters = action.endpoint.update(result, ...action.args);
Object.keys(updaters).forEach(key => {
endpoints[key] = updaters[key](endpoints[key]);
});
}
// no reason to completely fail because of user-code error
// integrity of this state update is still guaranteed
} catch (error) {
console.error(`Endpoint.update() error: ${action.key}`);
console.error(error);
}
return {
entities,
endpoints,
indexes,
meta: {
...state.meta,
[action.key]: {
date: action.meta.date,
fetchedAt: action.meta.fetchedAt,
expiresAt: action.meta.expiresAt,
prevExpiresAt: (_state$meta$action$ke = state.meta[action.key]) == null ? void 0 : _state$meta$action$ke.expiresAt
}
},
entitiesMeta,
optimistic: filterOptimistic(state, action),
lastReset: state.lastReset
};
// reducer must update the state, so in case of processing errors we simply compute the endpoints inline
} catch (error) {
if (typeof error === 'object') {
error.message = `Error processing ${action.key}\n\nFull Schema: ${JSON.stringify(action.endpoint.schema, undefined, 2)}\n\nError:\n${error.message}`;
if ('response' in action) error.response = action.response;
error.status = 400;
}
// this is not always bubbled up, so let's double sure this doesn't fail silently
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
console.error(error);
}
return reduceError(state, action, error);
}
}
function reduceError(state, action, error) {
if (error.name === 'AbortError') {
// In case we abort simply undo the optimistic update and act like no fetch even occured
// We still want those watching promises from fetch directly to observed the abort, but we don't want to
// Trigger errors in this case. This means theoretically improperly built aborts useSuspense() could suspend forever.
return {
...state,
optimistic: filterOptimistic(state, action)
};
}
return {
...state,
meta: {
...state.meta,
[action.key]: {
date: action.meta.date,
fetchedAt: action.meta.fetchedAt,
expiresAt: action.meta.expiresAt,
error,
errorPolicy: action.endpoint.errorPolicy == null ? void 0 : action.endpoint.errorPolicy(error)
}
},
optimistic: filterOptimistic(state, action)
};
}
/** Filter all requests with same serialization that did not start after the resolving request */
function filterOptimistic(state, resolvingAction) {
return state.optimistic.filter(optimisticAction => optimisticAction.key !== resolvingAction.key || (optimisticAction.type === OPTIMISTIC ? optimisticAction.meta.fetchedAt !== resolvingAction.meta.fetchedAt : optimisticAction.meta.date > resolvingAction.meta.date));
}
function createReducer(controller) {
return function reducer(state, action) {
if (!state) state = initialState$1;
switch (action.type) {
case GC:
// inline deletes are fine as these should have 0 refcounts
action.entities.forEach(({
key,
pk
}) => {
var _entities$key, _entitiesMeta$key;
(_entities$key = state.entities[key]) == null || delete _entities$key[pk];
(_entitiesMeta$key = state.entitiesMeta[key]) == null || delete _entitiesMeta$key[pk];
});
action.endpoints.forEach(fetchKey => {
delete state.endpoints[fetchKey];
delete state.meta[fetchKey];
});
return state;
case FETCH:
return fetchReducer(state, action);
case OPTIMISTIC:
// eslint-disable-next-line no-fallthrough
case SET_RESPONSE:
return setResponseReducer(state, action, controller);
case SET:
return setReducer(state, action, controller);
case INVALIDATEALL:
case INVALIDATE:
return invalidateReducer(state, action);
case EXPIREALL:
return expireReducer(state, action);
case RESET:
return {
...initialState$1,
lastReset: action.date
};
default:
// A reducer must always return a valid state.
// Alternatively you can throw an error if an invalid action is dispatched.
return state;
}
};
}
const initialState$1 = {
entities: {},
endpoints: {},
indexes: {},
meta: {},
entitiesMeta: {},
optimistic: [],
lastReset: 0
};
var __INTERNAL__ = /*#__PURE__*/Object.freeze({
__proto__: null,
MemoCache: normalizr.MemoCache,
initialState: initialState$1
});
function createSubscription(endpoint, {
args
}) {
return {
type: SUBSCRIBE,
key: endpoint.key(...args),
args,
endpoint
};
}
function createUnsubscription(endpoint, {
args
}) {
return {
type: UNSUBSCRIBE,
key: endpoint.key(...args),
args,
endpoint
};
}
function createSetResponse(endpoint, {
args,
fetchedAt,
response,
error = false
}) {
var _endpoint$errorExpiry, _endpoint$dataExpiryL;
const expiryLength = error ? (_endpoint$errorExpiry = endpoint.errorExpiryLength) != null ? _endpoint$errorExpiry : 1000 : (_endpoint$dataExpiryL = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL : 60000;
/* istanbul ignore next */
if (process.env.NODE_ENV === 'development' && expiryLength < 0) {
throw new Error('Negative expiry length are not allowed.');
}
return {
type: SET_RESPONSE,
key: endpoint.key(...args),
response,
args: args.map(ensurePojo),
endpoint,
meta: createMeta(expiryLength, fetchedAt),
error
};
}
function createSet(schema, {
args,
fetchedAt,
value
}) {
return {
type: SET,
value,
args: args.map(ensurePojo),
schema,
meta: createMeta(60000, fetchedAt)
};
}
function createReset() {
return {
type: RESET,
date: Date.now()
};
}
function createInvalidateAll(testKey) {
return {
type: INVALIDATEALL,
testKey
};
}
function createInvalidate(endpoint, {
args
}) {
return {
type: INVALIDATE,
key: endpoint.key(...args)
};
}
/**
* Requesting a fetch to begin
*/
function createFetch(endpoint, {
args
}) {
let resolve = 0;
let reject = 0;
const promise = new Promise((a, b) => {
[resolve, reject] = [a, b];
});
const meta = {
fetchedAt: Date.now(),
resolve,
reject,
promise
};
return {
type: FETCH,
key: endpoint.key(...args),
args,
endpoint,
meta
};
}
function createExpireAll(testKey) {
return {
type: EXPIREALL,
testKey
};
}
class ImmortalGCPolicy {
// eslint-disable-next-line @typescript-eslint/no-empty-function
init() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
cleanup() {}
createCountRef() {
return () => () => undefined;
}
}
function selectMeta(state, fetchKey) {
return state.meta[fetchKey];
}
const unsetDispatch = action => {
throw new Error(`Dispatching while constructing your middleware is not allowed. ` + `Other middleware would not be applied to this dispatch.`);
};
const unsetState = () => {
// This is only the value until it is set by the DataProvider
/* istanbul ignore next */
return initialState$1;
};
/**
* Imperative control of Reactive Data Client store
* @see https://dataclient.io/docs/api/Controller
*/
class Controller {
/**
* Dispatches an action to Reactive Data Client reducer.
*
* @see https://dataclient.io/docs/api/Controller#dispatch
*/
/**
* Gets the latest state snapshot that is fully committed.
*
* This can be useful for imperative use-cases like event handlers.
* This should *not* be used to render; instead useSuspense() or useCache()
* @see https://dataclient.io/docs/api/Controller#getState
*/
/**
* Singleton to maintain referential equality between calls
*/
/**
* Handles garbage collection
*/
constructor({
dispatch = unsetDispatch,
getState = unsetState,
memo = new normalizr.MemoCache(),
gcPolicy = new ImmortalGCPolicy()
} = {}) {
this._dispatch = dispatch;
this.getState = getState;
this.memo = memo;
this.gcPolicy = gcPolicy;
}
// TODO: drop when drop support for destructuring (0.14 and below)
set dispatch(dispatch) {
/* istanbul ignore next */
this._dispatch = dispatch;
}
// TODO: drop when drop support for destructuring (0.14 and below)
get dispatch() {
return this._dispatch;
}
bindMiddleware({
dispatch,
getState
}) {
this._dispatch = dispatch;
this.getState = getState;
}
/*************** Action Dispatchers ***************/
/**
* Fetches the endpoint with given args, updating the Reactive Data Client cache with the response or error upon completion.
* @see https://dataclient.io/docs/api/Controller#fetch
*/
fetch = (endpoint, ...args) => {
const action = createFetch(endpoint, {
args
});
this.dispatch(action);
if (endpoint.schema) {
return action.meta.promise.then(input => normalizr.denormalize(endpoint.schema, input, {}, args));
}
return action.meta.promise;
};
/**
* Fetches only if endpoint is considered 'stale'; otherwise returns undefined
* @see https://dataclient.io/docs/api/Controller#fetchIfStale
*/
fetchIfStale = (endpoint, ...args) => {
const {
data,
expiresAt,
expiryStatus
} = this.getResponseMeta(endpoint, ...args, this.getState());
if (expiryStatus !== normalizr.ExpiryStatus.Invalid && Date.now() <= expiresAt) return data;
return this.fetch(endpoint, ...args);
};
/**
* Forces refetching and suspense on useSuspense with the same Endpoint and parameters.
* @see https://dataclient.io/docs/api/Controller#invalidate
*/
invalidate = (endpoint, ...args) => args[0] !== null ? this.dispatch(createInvalidate(endpoint, {
args: args
})) : Promise.resolve();
/**
* Forces refetching and suspense on useSuspense on all matching endpoint result keys.
* @see https://dataclient.io/docs/api/Controller#invalidateAll
* @returns Promise that resolves when invalidation is commited.
*/
invalidateAll = options => this.dispatch(createInvalidateAll(key => options.testKey(key)));
/**
* Sets all matching endpoint result keys to be STALE.
* @see https://dataclient.io/docs/api/Controller#expireAll
* @returns Promise that resolves when expiry is commited. *NOT* fetch promise
*/
expireAll = options => this.dispatch(createExpireAll(key => options.testKey(key)));
/**
* Resets the entire Reactive Data Client cache. All inflight requests will not resolve.
* @see https://dataclient.io/docs/api/Controller#resetEntireStore
*/
resetEntireStore = () => this.dispatch(createReset());
/**
* Sets value for the Queryable and args.
* @see https://dataclient.io/docs/api/Controller#set
*/
set(schema, ...rest) {
const value = rest[rest.length - 1];
const action = createSet(schema, {
args: rest.slice(0, rest.length - 1),
value
});
// TODO: reject with error if this fails in reducer
return this.dispatch(action);
}
/**
* Sets response for the Endpoint and args.
* @see https://dataclient.io/docs/api/Controller#setResponse
*/
setResponse = (endpoint, ...rest) => {
const response = rest[rest.length - 1];
const action = createSetResponse(endpoint, {
args: rest.slice(0, rest.length - 1),
response
});
return this.dispatch(action);
};
/**
* Sets an error response for the Endpoint and args.
* @see https://dataclient.io/docs/api/Controller#setError
*/
setError = (endpoint, ...rest) => {
const response = rest[rest.length - 1];
const action = createSetResponse(endpoint, {
args: rest.slice(0, rest.length - 1),
response,
error: true
});
return this.dispatch(action);
};
/**
* Resolves an inflight fetch.
* @see https://dataclient.io/docs/api/Controller#resolve
*/
resolve = (endpoint, meta) => {
return this.dispatch(createSetResponse(endpoint, meta));
};
/**
* Marks a new subscription to a given Endpoint.
* @see https://dataclient.io/docs/api/Controller#subscribe
*/
subscribe = (endpoint, ...args) => args[0] !== null ? this.dispatch(createSubscription(endpoint, {
args: args
})) : Promise.resolve();
/**
* Marks completion of subscription to a given Endpoint.
* @see https://dataclient.io/docs/api/Controller#unsubscribe
*/
unsubscribe = (endpoint, ...args) => args[0] !== null ? this.dispatch(createUnsubscription(endpoint, {
args: args
})) : Promise.resolve();
/*************** More ***************/
/* TODO:
abort = <E extends EndpointInterface>(
endpoint: E,
...args: readonly [...Parameters<E>]
): Promise<void>
*/
/**
* Gets a snapshot (https://dataclient.io/docs/api/Snapshot)
* @see https://dataclient.io/docs/api/Controller#snapshot
*/
snapshot = (state, fetchedAt) => {
return new Snapshot(this, state, fetchedAt);
};
/**
* Gets the error, if any, for a given endpoint. Returns undefined for no errors.
* @see https://dataclient.io/docs/api/Controller#getError
*/
getError(endpoint, ...rest) {
if (rest[0] === null) return;
const state = rest[rest.length - 1];
// this is typescript generics breaking
const args = rest.slice(0, rest.length - 1);
const key = endpoint.key(...args);
const meta = selectMeta(state, key);
const error = state.endpoints[key];
if (error !== undefined && (meta == null ? void 0 : meta.errorPolicy) === 'soft') return;
return meta == null ? void 0 : meta.error;
}
/**
* Gets the (globally referentially stable) response for a given endpoint/args pair from state given.
* @see https://dataclient.io/docs/api/Controller#getResponse
*/
getResponse(endpoint, ...rest) {
// TODO: breaking: only return data
return this.getResponseMeta(endpoint, ...rest);
}
/**
* Gets the (globally referentially stable) response for a given endpoint/args pair from state given.
* @see https://dataclient.io/docs/api/Controller#getResponseMeta
*/
getResponseMeta(endpoint, ...rest) {
const [state, args] = extractStateAndArgs(rest);
const isActive = args.length !== 1 || args[0] !== null;
const key = isActive ? endpoint.key(...args) : '';
const cacheEndpoints = isActive ? state.endpoints[key] : undefined;
const schema = endpoint.schema;
const meta = selectMeta(state, key);
let expiresAt = meta == null ? void 0 : meta.expiresAt;
// if we have no endpoint entry, and our endpoint has a schema - try querying the store
const shouldQuery = cacheEndpoints === undefined && schema !== undefined;
const input = shouldQuery ?
// nothing in endpoints cache, so try querying if we have a schema to do so
this.memo.buildQueryKey(schema, args, state, key) : cacheEndpoints;
if (!isActive) {
// when not active simply return the query input without denormalizing
return {
data: input,
expiryStatus: normalizr.ExpiryStatus.Valid,
expiresAt: Infinity,
countRef: () => () => undefined
};
}
let isInvalid = false;
if (shouldQuery) {
isInvalid = !normalizr.validateQueryKey(input);
// endpoint without entities
} else if (!schema || !requiresDenormalize(schema)) {
return {
data: cacheEndpoints,
expiryStatus: this.getExpiryStatus(cacheEndpoints === undefined, !!endpoint.invalidIfStale, meta),
expiresAt: expiresAt || 0,
countRef: this.gcPolicy.createCountRef({
key
})
};
}
const {
data,
paths
} = this.memo.denormalize(schema, input, state.entities, args);
if (!expiresAt) {
// note: isInvalid can only be true if shouldQuery is true
if (isInvalid) expiresAt = 1;
// fallback to entity expiry time
else expiresAt = entityExpiresAt(paths, state.entitiesMeta);
}
return {
data,
expiryStatus: this.getExpiryStatus(typeof data === 'symbol', !!endpoint.invalidIfStale || isInvalid, meta),
expiresAt,
countRef: this.gcPolicy.createCountRef({
key,
paths
})
};
}
/**
* Queries the store for a Querable schema
* @see https://dataclient.io/docs/api/Controller#get
*/
get(schema, ...rest) {
const [state, args] = extractStateAndArgs(rest);
const {
data
} = this.memo.query(schema, args, state);
return typeof data === 'symbol' ? undefined : data;
}
/**
* Queries the store for a Querable schema; providing related metadata
* @see https://dataclient.io/docs/api/Controller#getQueryMeta
*/
getQueryMeta(schema, ...rest) {
const [state, args] = extractStateAndArgs(rest);
const {
data,
paths
} = this.memo.query(schema, args, state);
return {
data: typeof data === 'symbol' ? undefined : data,
countRef: this.gcPolicy.createCountRef({
paths
})
};
}
getExpiryStatus(invalidData, invalidIfStale, meta = {}) {
// https://dataclient.io/docs/concepts/expiry-policy#expiry-status
// we don't track the difference between stale or fresh because that is tied to triggering
// conditions
return meta.invalidated || invalidData && !meta.error ? normalizr.ExpiryStatus.Invalid : invalidData || invalidIfStale ? normalizr.ExpiryStatus.InvalidIfStale : normalizr.ExpiryStatus.Valid;
}
}
// benchmark: https://www.measurethat.net/Benchmarks/Show/24691/0/min-reducer-vs-imperative-with-paths
// earliest expiry dictates age
function entityExpiresAt(paths, entitiesMeta) {
let expiresAt = Infinity;
for (const {
key,
pk
} of paths) {
var _entitiesMeta$key;
const entityExpiry = (_entitiesMeta$key = entitiesMeta[key]) == null || (_entitiesMeta$key = _entitiesMeta$key[pk]) == null ? void 0 : _entitiesMeta$key.expiresAt;
// expiresAt will always resolve to false with any comparison
if (entityExpiry < expiresAt) expiresAt = entityExpiry;
}
return expiresAt;
}
/** Whether `denormalize()` must run to reconstruct the response.
*
* True iff some node in the schema tree defines `normalize` — meaning it
* transforms the response when written (entity write, polymorphic hoist,
* lazy delegation, etc.), so the cached value differs from the response.
* Mirrors the dispatch in `getVisit.ts`: schema classes are detected here
* by the same hook the visit walker uses, so we never need to walk their
* instance fields.
*/
function requiresDenormalize(schema) {
if (!schema) return false;
if (Array.isArray(schema)) return schema.length !== 0 && requiresDenormalize(schema[0]);
// Must reject primitives before probing `.normalize` — `String.prototype.normalize` exists.
const t = typeof schema;
if (t !== 'object' && t !== 'function') return false;
if (typeof schema.normalize === 'function') return true;
// Plain-object schema map (e.g. `{ data: [Tacos], page: { ... } }`).
return Object.values(schema).some(x => requiresDenormalize(x));
}
class Snapshot {
static abort = new AbortOptimistic();
state;
controller;
fetchedAt;
abort = Snapshot.abort;
constructor(controller, state, fetchedAt = 0) {
this.state = state;
this.controller = controller;
this.fetchedAt = fetchedAt;
}
/*************** Data Access ***************/
/** @see https://dataclient.io/docs/api/Snapshot#getResponse */
getResponse(endpoint, ...args) {
return this.controller.getResponse(endpoint, ...args, this.state);
}
/** @see https://dataclient.io/docs/api/Snapshot#getResponseMeta */
getResponseMeta(endpoint, ...args) {
return this.controller.getResponseMeta(endpoint, ...args, this.state);
}
/** @see https://dataclient.io/docs/api/Snapshot#getError */
getError(endpoint, ...args) {
return this.controller.getError(endpoint, ...args, this.state);
}
/**
* Retrieved memoized value for any Querable schema
* @see https://dataclient.io/docs/api/Snapshot#get
*/
get(schema, ...args) {
return this.controller.get(schema, ...args, this.state);
}
/**
* Queries the store for a Querable schema; providing related metadata
* @see https://dataclient.io/docs/api/Snapshot#getQueryMeta
*/
getQueryMeta(schema, ...args) {
return this.controller.getQueryMeta(schema, ...args, this.state);
}
}
/** Extract state and args from rest params, applying ensurePojo to args */
function extractStateAndArgs(rest) {
const l = rest.length;
const args = new Array(l - 1);
for (let i = 0; i < l - 1; i++) {
// handle FormData
args[i] = ensurePojo(rest[i]);
}
// this is typescript generics breaking
return [rest[l - 1], args];
}
var _DevToolsManager;
let DEFAULT_CONFIG = {};
if (process.env.NODE_ENV !== 'production') {
var _globalThis$document;
const extraEndpointKeys = ['dataExpiryLength', 'errorExpiryLength', 'errorPolicy', 'invalidIfStale', 'pollFrequency', 'getOptimisticResponse', 'update'];
function serializeEndpoint(endpoint) {
var _toJSON, _endpoint$schema;
const serial = {
name: endpoint.name,
schema: (_toJSON = (_endpoint$schema = endpoint.schema) == null || _endpoint$schema.toJSON == null ? void 0 : _endpoint$schema.toJSON()) != null ? _toJSON : endpoint.schema,
sideEffect: endpoint.sideEffect
};
extraEndpointKeys.forEach(key => {
if (key in endpoint) serial[key] = endpoint[key];
});
return serial;
}
const HASINTL = typeof Intl !== 'undefined';
DEFAULT_CONFIG = {
name: `Data Client: ${(_globalThis$document = globalThis.document) == null ? void 0 : _globalThis$document.title}`,
autoPause: true,
features: {
pause: true,
// start/pause recording of dispatched actions
lock: true,
// lock/unlock dispatching actions and side effects
persist: false,
// persist states on page reloading
export: true,
// export history of actions in a file
import: 'custom',
// import history of actions from a file
jump: true,
// jump back and forth (time travelling)
skip: true,
// skip (cancel) actions
reorder: true,
// drag and drop actions in the history list
dispatch: false,
// dispatch custom actions or action creators
test: false // generate tests for the selected actions
},
actionSanitizer: action => {
if (!('endpoint' in action)) return action;
return {
...action,
endpoint: serializeEndpoint(action.endpoint)
};
},
serialize: {
options: undefined,
/* istanbul ignore next */
replacer: HASINTL ? (key, value) => {
if (typeof value === 'number' && typeof key === 'string' && isFinite(value) && (key === 'date' || key.endsWith('At'))) {
return Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
fractionalSecondDigits: 3
}).format(value);
}
return value;
} : undefined
}
};
}
/** Integrates with https://github.com/reduxjs/redux-devtools
*
* Options: https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md
*
* @see https://dataclient.io/docs/api/DevToolsManager
*/
class DevToolsManager {
constructor(config, skipLogging) {
var _options$name, _globalThis$document2;
this.started = false;
this.actions = [];
this.maxBufferLength = 100;
/* istanbul ignore next */
const options = {
...DEFAULT_CONFIG,
...config
};
this.devtoolsName = (_options$name = options.name) != null ? _options$name : `Data Client: ${(_globalThis$document2 = globalThis.document) == null ? void 0 : _globalThis$document2.title}`;
this.devTools = typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__.connect(options);
// we cut it in half so we should double so we don't lose
if (config != null && config.maxAge) this.maxBufferLength = config.maxAge * 2;
if (skipLogging) this.skipLogging = skipLogging;
}
handleAction(action, state) {
if (this.started) {
this.devTools.send(action, state);
} else {
// avoid this getting too big in case this is long running
// we cut in half so we aren't constantly reallocating
if (this.actions.length > this.maxBufferLength) this.actions = this.actions.slice(this.maxBufferLength / 2);
// queue actions
this.actions.push([action, state]);
}
}
/** Called when initial state is ready */
init(state) {
if (process.env.NODE_ENV !== 'production') {
var _ref, _ref$__DC_CONTROLLERS;
((_ref$__DC_CONTROLLERS = (_ref = globalThis).__DC_CONTROLLERS__) != null ? _ref$__DC_CONTROLLERS : _ref.__DC_CONTROLLERS__ = new Map()).set(this.devtoolsName, this.controller);
}
if (process.env.NODE_ENV !== 'production' && this.devTools) {
this.devTools.init(state);
this.devTools.subscribe(msg => {
switch (msg.type) {
case 'START':
this.started = true;
if (this.actions.length) {
this.actions.forEach(([action, state]) => {
this.handleAction(action, state);
});
this.actions = [];
}
break;
case 'STOP':
this.started = false;
break;
case 'DISPATCH':
if (msg.payload.type === 'RESET') {
this.controller.resetEntireStore();
}
break;
}
});
}
}
/** Ensures all subscriptions are cleaned up. */
cleanup() {
if (process.env.NODE_ENV !== 'production') {
const map = globalThis.__DC_CONTROLLERS__;
if ((map == null ? void 0 : map.get(this.devtoolsName)) === this.controller) {
map.delete(this.devtoolsName);
}
}
}
}
_DevToolsManager = DevToolsManager;
(() => {
/* istanbul ignore if */
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'production') {
_DevToolsManager.prototype.middleware = function (controller) {
this.controller = controller;
if (!this.devTools) return next => action => next(action);
const reducer = createReducer(controller);
let state = controller.getState();
return next => action => {
var _this$skipLogging;
const shouldSkip = (_this$skipLogging = this.skipLogging) == null ? void 0 : _this$skipLogging.call(this, action);
const ret = next(action);
if (this.started) {
// we track state changes here since getState() will only update after a batch commit
state = reducer(state, action);
} else {
state = controller.getState();
}
ret.then(() => {
if (shouldSkip) return;
this.handleAction(action, state.optimistic.reduce(reducer, state));
});
return ret;
};
};
} else {
_DevToolsManager.prototype.middleware = () => next => action => next(action);
}
})();
async function collapseFixture(fixture, args, interceptorData) {
let error = 'error' in fixture ? fixture.error : false;
let response = fixture.response;
if (typeof fixture.response === 'function') {
try {
response = await fixture.response.apply(interceptorData, args);
// dispatch goes through user-code that can sometimes fail.
// let's ensure we always handle errors
} catch (e) {
response = e;
error = true;
}
}
return {
response,
error
};
}
function createFixtureMap(fixtures = []) {
const map = new Map();
const computed = [];
for (const fixture of fixtures) {
if ('args' in fixture) {
if (typeof fixture.response !== 'function') {
const key = fixture.endpoint.key(...fixture.args);
map.set(key, fixture);
} else {
// this has to be a typo. probably needs to remove args
console.warn(`Fixture found with function response, and explicit args. Interceptors should not specify args.
${fixture.endpoint.name}: ${JSON.stringify(fixture.args)}
Treating as Interceptor`);
computed.push(fixture);
}
} else {
computed.push(fixture);
}
}
return [map, computed];
}
function MockController(Base, {
fixtures = [],
getInitialInterceptorData = () => ({})
}) {
const [fixtureMap, interceptors] = createFixtureMap(fixtures);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return class MockedController extends Base {
// legacy compatibility (re-declaration)
// TODO: drop when drop support for destructuring (0.14 and below)
fixtureMap = fixtureMap;
interceptors = interceptors;
interceptorData = getInitialInterceptorData();
constructor(...args) {
super(...args);
// legacy compatibility
// TODO: drop when drop support for destructuring (0.14 and below)
if (!this._dispatch) {
this._dispatch = args[0].dispatch;
}
}
// legacy compatibility - we need this to work with 0.14 and below as they do not have this setter
// TODO: drop when drop support for destructuring (0.14 and below)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
set dispatch(dispatch) {
this._dispatch = dispatch;
}
get dispatch() {
return action => {
var _actionTypes$FETCH;
// support legacy that has _TYPE suffix
if (action.type === ((_actionTypes$FETCH = FETCH) != null ? _actionTypes$FETCH : FETCH_TYPE)) {
// eslint-disable-next-line prefer-const
let {
key,
args
} = action;
let fixture;
if (this.fixtureMap.has(key)) {
fixture = this.fixtureMap.get(key);
if (!args) args = fixture.args;
// exact matches take priority; now test ComputedFixture
} else {
for (const cfix of this.interceptors) {
if (cfix.endpoint.testKey(key)) {
fixture = cfix;
break;
}
}
}
// we have a match
if (fixture !== undefined) {
var _fixture$delay;
const replacedAction = {
...action
};
const delayMs = typeof fixture.delay === 'function' ? fixture.delay(...args) : (_fixture$delay = fixture.delay) != null ? _fixture$delay : 0;
if ('fetchResponse' in fixture) {
const {
fetchResponse
} = fixture;
fixture = {
endpoint: fixture.endpoint,
response(...args) {
const endpoint = action.endpoint.extend({
fetchResponse: (input, init) => {
const ret = fetchResponse.call(this, input, init);
return Promise.resolve(new Response(JSON.stringify(ret), {
status: 200,
headers: new Headers({
'Content-Type': 'application/json'
})
}));
}
});
return endpoint(...args);
}
};
}
const fetch = async () => {
if (!fixture) {
throw new Error('No fixture found');
}
// delayCollapse determines when the fixture function is 'collapsed' (aka 'run')
// collapsed: https://en.wikipedia.org/wiki/Copenhagen_interpretation
if (fixture.delayCollapse) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
const result = await collapseFixture(fixture, args, this.interceptorData);
if (!fixture.delayCollapse && delayMs) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
if (result.error) {
throw result.response;
}
return result.response;
};
if (typeof replacedAction.endpoint.extend === 'function') {
replacedAction.endpoint = replacedAction.endpoint.extend({
fetch
});
} else {
// TODO: full testing of this
replacedAction.endpoint = fetch;
replacedAction.endpoint.__proto__ = action.endpoint;
}
// TODO: make super.dispatch (once we drop support for destructuring)
return this._dispatch(replacedAction);
}
}
// TODO: make super.dispatch (once we drop support for destructuring)
return this._dispatch(action);
};
}
};
}
const {
initialState
} = __INTERNAL__;
function mockInitialState(fixtures = []) {
const actions = [];
const dispatch = action => {
actions.push(action);
return Promise.resolve();
};
const controller = new Controller({
dispatch
});
const reducer = createReducer(controller);
fixtures.forEach(fixture => {
dispatchFixture(fixture, fixture.args, controller);
});
return actions.reduce(reducer, initialState);
}
function dispatchFixture(fixture, args, controller, fetchedAt) {
// eslint-disable-next-line prefer-const
let {
endpoint
} = fixture;
const {
response,
error
} = fixture;
if (controller.resolve) {
controller.resolve(endpoint, {
args,
response,
error,
fetchedAt: Date.now()
});
} else {
if (error === true) {
controller.setError(endpoint, ...args, response);
} else {
controller.setResponse(endpoint, ...args, response);
}
}
}
exports.MockController = MockController;
exports.collapseFixture = collapseFixture;
exports.createFixtureMap = createFixtureMap;
exports.mockInitialState = mockInitialState;