@data-client/core
Version:
Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch
191 lines (178 loc) • 6.17 kB
text/typescript
import type { DevToolsConfig } from './devtoolsTypes.js';
import type { Controller, EndpointInterface } from '../index.js';
import type { Middleware } from '../middlewareTypes.js';
import createReducer from '../state/reducer/createReducer.js';
import type { Manager, State, ActionTypes } from '../types.js';
export type { DevToolsConfig };
let DEFAULT_CONFIG = {};
if (process.env.NODE_ENV !== 'production') {
const extraEndpointKeys = [
'dataExpiryLength',
'errorExpiryLength',
'errorPolicy',
'invalidIfStale',
'pollFrequency',
'getOptimisticResponse',
'update',
];
function serializeEndpoint(endpoint: EndpointInterface) {
const serial: any = {
name: endpoint.name,
schema: (endpoint.schema as any)?.toJSON?.() ?? endpoint.schema,
sideEffect: endpoint.sideEffect,
};
extraEndpointKeys.forEach(key => {
if (key in endpoint)
serial[key] = endpoint[key as keyof EndpointInterface];
});
return serial;
}
const HASINTL = typeof Intl !== 'undefined';
DEFAULT_CONFIG = {
name: `Data Client: ${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: ActionTypes) => {
if (!('endpoint' in action)) return action;
return {
...action,
endpoint: serializeEndpoint(action.endpoint),
};
},
serialize: {
options: undefined,
/* istanbul ignore next */
replacer:
HASINTL ?
(key: string | number | symbol, value: unknown) => {
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
*/
export default class DevToolsManager implements Manager {
declare middleware: Middleware;
declare protected devTools: undefined | any;
protected started = false;
protected actions: [ActionTypes, State<unknown>][] = [];
declare protected controller: Controller;
declare skipLogging?: (action: ActionTypes) => boolean;
maxBufferLength = 100;
constructor(
config?: DevToolsConfig,
skipLogging?: (action: ActionTypes) => boolean,
) {
/* istanbul ignore next */
this.devTools =
typeof window !== 'undefined' &&
(window as any).__REDUX_DEVTOOLS_EXTENSION__ &&
(window as any).__REDUX_DEVTOOLS_EXTENSION__.connect({
...DEFAULT_CONFIG,
...config,
});
// we cut it in half so we should double so we don't lose
if (config?.maxAge) this.maxBufferLength = config.maxAge * 2;
if (skipLogging) this.skipLogging = skipLogging;
}
static {
/* istanbul ignore if */
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'production') {
this.prototype.middleware = function (controller) {
if (!this.devTools) return next => action => next(action);
this.controller = controller;
const reducer = createReducer(controller as any);
let state = controller.getState();
return next => action => {
const shouldSkip = this.skipLogging?.(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 {
this.prototype.middleware = () => next => action => next(action);
}
}
handleAction(action: any, state: any) {
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: State<any>) {
if (process.env.NODE_ENV !== 'production' && this.devTools) {
this.devTools.init(state);
this.devTools.subscribe((msg: any) => {
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() {}
}