@makerdao/multicall
Version:
A blockchain state management library for dapps.
351 lines (330 loc) • 10.6 kB
JavaScript
import WebSocket from 'isomorphic-ws';
import aggregate from './aggregate';
import { isEmpty } from './helpers';
import addresses from './addresses.json';
import debug from 'debug';
const log = debug('multicall');
const reWsEndpoint = /^wss?:\/\//i;
function isNewState(type, value, store) {
return (
store[type] === undefined ||
(value !== null &&
store[type] !== null &&
typeof value === 'object' &&
typeof value.toString === 'function' &&
typeof store[type] === 'object' &&
typeof store[type].toString === 'function'
? value.toString() !== store[type].toString()
: value !== store[type])
);
}
function prepareConfig(config) {
config = {
interval: 1000,
staleBlockRetryWait: 3000,
errorRetryWait: 5000,
wsResponseTimeout: 5000,
wsReconnectTimeout: 5000,
...config
};
if (config.preset !== undefined) {
if (addresses[config.preset] !== undefined) {
config.multicallAddress = addresses[config.preset].multicall;
config.rpcUrl = addresses[config.preset].rpcUrl;
} else throw new Error(`Unknown preset ${config.preset}`);
}
return config;
}
export default function createWatcher(model, config) {
const state = {
model: [...model],
store: {},
storeTransformed: {},
keyToArgMap: {},
latestPromiseId: 0,
latestBlockNumber: null,
id: 0,
listeners: {
subscribe: [],
block: [],
poll: [],
error: []
},
handler: null,
wsReconnectHandler: null,
watching: false,
config: prepareConfig(config),
ws: null
};
function reconnectWebSocket(timeout) {
clearTimeout(state.handler);
state.handler = null;
clearTimeout(state.wsReconnectHandler);
state.wsReconnectHandler = setTimeout(() => {
destroyWebSocket();
setupWebSocket();
}, timeout);
}
function setupWebSocket() {
if (reWsEndpoint.test(state.config.rpcUrl)) {
log(`Connecting to WebSocket ${state.config.rpcUrl}...`);
state.ws = new WebSocket(state.config.rpcUrl);
state.ws.onopen = () => {
log('WebSocket connected');
if (state.handler) throw new Error('Existing poll setTimeout handler set');
if (state.watching) {
poll.call({
state,
interval: 0,
resolveFetchPromise: state.initialFetchResolver
});
}
};
state.ws.onclose = err => {
log('WebSocket closed: %s', JSON.stringify(err));
log(`Reconnecting in ${state.config.wsReconnectTimeout / 1000} seconds.`);
reconnectWebSocket(state.config.wsReconnectTimeout);
};
state.ws.onerror = err => {
log('WebSocket error: %s', JSON.stringify(err));
log(`Reconnecting in ${state.config.wsReconnectTimeout / 1000} seconds.`);
reconnectWebSocket(state.config.wsReconnectTimeout);
};
}
}
function destroyWebSocket() {
log('destroyWebSocket()');
state.ws.onopen = null;
state.ws.onclose = null;
state.ws.onerror = null;
state.ws.onmessage = null;
state.ws.close();
}
setupWebSocket();
state.initialFetchPromise = new Promise(resolve => {
state.initialFetchResolver = resolve;
});
function subscribe(listener, id, batch = false) {
if (!isEmpty(state.storeTransformed)) {
const events = Object.entries(state.storeTransformed).map(([type, value]) => ({
type,
value,
args: state.keyToArgMap[type] || []
}));
batch ? listener(events) : events.forEach(listener);
}
state.listeners.subscribe.push({ listener, id, batch });
}
function alertListeners(events) {
if (!isEmpty(events))
state.listeners.subscribe.forEach(({ listener, batch }) =>
batch ? listener(events) : events.forEach(listener)
);
}
function poll() {
const interval = this.interval !== undefined ? this.interval : this.state.config.interval;
log('poll() called, %s%s', 'interval: ' + interval, this.retry ? ', retry: ' + this.retry : '');
this.state.handler = setTimeout(async () => {
try {
if (!this.state.handler) return;
this.state.latestPromiseId++;
const promiseId = this.state.latestPromiseId;
state.listeners.poll.forEach(({ listener }) =>
listener({
id: promiseId,
latestBlockNumber: this.state.latestBlockNumber,
...(this.retry ? { retry: this.retry } : {})
})
);
const {
results: {
blockNumber,
original: { ...data },
transformed: { ...dataTransformed }
},
keyToArgMap
} = await aggregate(this.state.model, {
...this.state.config,
ws: this.state.ws,
id: this.state.latestPromiseId
});
if (this.state.cancelPromiseId === promiseId) return;
if (typeof this.resolveFetchPromise === 'function') this.resolveFetchPromise();
if (this.state.latestBlockNumber !== null && blockNumber < this.state.latestBlockNumber) {
// Retry if blockNumber is lower than latestBlockNumber
log(
`Stale block returned, retrying in ${this.state.config.staleBlockRetryWait /
1000} seconds`
);
poll.call({
state: this.state,
interval: this.state.config.staleBlockRetryWait,
retry: this.retry ? this.retry + 1 : 1
});
} else {
if (
this.state.latestBlockNumber === null ||
(this.state.latestBlockNumber !== null && blockNumber > this.state.latestBlockNumber)
) {
this.state.latestBlockNumber = parseInt(blockNumber);
state.listeners.block.forEach(({ listener }) => listener(this.state.latestBlockNumber));
}
const events = Object.entries(data)
.filter(([type, value]) => isNewState(type, value, this.state.store))
.map(([type]) => ({
type,
value: dataTransformed[type],
args: keyToArgMap[type] || []
}));
this.state.store = { ...data };
this.state.storeTransformed = { ...dataTransformed };
this.state.keyToArgMap = { ...keyToArgMap };
alertListeners(events);
poll.call({ state: this.state });
}
} catch (err) {
log('Error: %s', err.message);
state.listeners.error.forEach(({ listener }) => listener(err, this.state));
if (!this.state.handler) return;
// Retry on error
log(`Error occured, retrying in ${this.state.config.errorRetryWait / 1000} seconds`);
poll.call({
state: this.state,
interval: this.state.config.errorRetryWait,
retry: this.retry ? this.retry + 1 : 1
});
}
}, interval);
}
const watcher = {
tap(transform) {
log('watcher.tap() called');
const nextModel = transform([...state.model]);
state.model = [...nextModel];
return this.poll();
},
poll() {
log('watcher.poll() called');
let resolveFetchPromise;
const fetchPromise = new Promise(resolve => {
resolveFetchPromise = resolve;
});
if (state.watching && (!state.ws || state.ws.readyState === WebSocket.OPEN)) {
clearTimeout(state.handler);
state.handler = null;
poll.call({ state, interval: 0, resolveFetchPromise });
return fetchPromise;
}
return Promise.resolve();
},
subscribe(listener) {
const id = state.id++;
subscribe(listener, id, false);
return {
unsub() {
state.listeners.subscribe = state.listeners.subscribe.filter(({ id: _id }) => _id !== id);
}
};
},
batch() {
return {
subscribe(listener) {
const id = state.id++;
subscribe(listener, id, true);
return {
unsub() {
state.listeners.subscribe = state.listeners.subscribe.filter(({ id: _id }) => _id !== id);
}
};
}
};
},
onNewBlock(listener) {
const id = state.id++;
state.latestBlockNumber && listener(state.latestBlockNumber);
state.listeners.block.push({ listener, id });
return {
unsub() {
state.listeners.block = state.listeners.block.filter(({ id: _id }) => _id !== id);
}
};
},
onPoll(listener) {
const id = state.id++;
state.listeners.poll.push({ listener, id });
return {
unsub() {
state.listeners.poll = state.listeners.poll.filter(({ id: _id }) => _id !== id);
}
};
},
onError(listener) {
const id = state.id++;
state.listeners.error.push({ listener, id });
return {
unsub() {
state.listeners.error = state.listeners.error.filter(({ id: _id }) => _id !== id);
}
};
},
start() {
log('watcher.start() called');
state.watching = true;
if (!state.ws || state.ws.readyState === WebSocket.OPEN) {
poll.call({
state,
interval: 0,
resolveFetchPromise: state.initialFetchResolver
});
}
return state.initialFetchPromise;
},
stop() {
log('watcher.stop() called');
clearTimeout(state.handler);
state.handler = null;
clearTimeout(state.wsReconnectHandler);
state.wsReconnectHandler = null;
state.watching = false;
},
recreate(model, config) {
log('watcher.recreate() called');
clearTimeout(state.handler);
state.handler = null;
clearTimeout(state.wsReconnectHandler);
state.wsReconnectHandler = null;
if (state.ws) destroyWebSocket();
state.ws = null;
state.config = prepareConfig(config);
state.model = [...model];
state.store = {};
state.storeTransformed = {};
state.latestBlockNumber = null;
state.cancelPromiseId = state.latestPromiseId;
setupWebSocket();
if (state.watching && !state.ws) {
let resolveFetchPromise;
const fetchPromise = new Promise(resolve => {
resolveFetchPromise = resolve;
});
poll.call({
state,
interval: 0,
resolveFetchPromise
});
return fetchPromise;
}
return Promise.resolve();
},
awaitInitialFetch() {
return state.initialFetchPromise;
},
get initialFetch() {
return state.initialFetchPromise;
},
get schemas() {
return state.model;
}
};
return watcher;
}