diff --git a/src/bundles/analytics.js b/src/bundles/analytics.js index 0f5ff47df..0af2438ee 100644 --- a/src/bundles/analytics.js +++ b/src/bundles/analytics.js @@ -10,6 +10,7 @@ import { ACTIONS as CONIFG } from './config-save.js' import { ACTIONS as INIT } from './ipfs-provider.js' import { ACTIONS as EXP } from './experiments.js' import { getDeploymentEnv } from '../env.js' +import { onlyOnceAfter } from '../lib/hofs/functions.js' /** * @typedef {import('./ipfs-provider').Init} Init @@ -148,6 +149,45 @@ function removeConsent (consent, store) { } } +/** + * Add an event to countly. + * + * @param {Object} param0 + * @param {string} param0.id + * @param {number} param0.duration + */ +function addEvent ({ id, duration }) { + root.Countly.q.push(['add_event', { + key: id, + count: 1, + dur: duration + }]) +} + +/** + * You can limit how many times an event is recorded by adding them here. + */ +const addEventLimitedFns = new Map([ + ['IPFS_INIT_FAILED', onlyOnceAfter(addEvent, 5)] +]) + +/** + * Add an event to by using a limited addEvent fn if one is defined, or calling + * `addEvent` directly. + * + * @param {Object} param0 + * @param {string} param0.id + * @param {number} param0.duration + */ +function addEventWrapped ({ id, duration }) { + const fn = addEventLimitedFns.get(id) + if (fn) { + fn({ id, duration }) + } else { + addEvent({ id, duration }) + } +} + /** * @typedef {import('redux-bundler').Selectors} Selectors */ @@ -306,6 +346,7 @@ const createAnalyticsBundle = ({ * @param {Store} store */ init: async (store) => { + // LogRocket.init('sfqf1k/ipfs-webui') // test code sets a mock Counly instance on the global. if (!root.Countly) { root.Countly = {} @@ -375,16 +416,14 @@ const createAnalyticsBundle = ({ const payload = parseTask(action) if (payload) { const { id, duration, error } = payload - root.Countly.q.push(['add_event', { - key: id, - count: 1, - dur: duration - }]) + addEventWrapped({ id, duration }) // Record errors. Only from explicitly selected actions. if (error) { root.Countly.q.push(['add_log', action.type]) root.Countly.q.push(['log_error', error]) + // LogRocket.error(error) + // logger.error('Error in action', action.type, error) } } diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index 4743d592f..4970ee356 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -366,7 +366,11 @@ const actions = { } const result = await getIpfs({ - // @ts-ignore - TS can't seem to infer connectionTest option + /** + * + * @param {import('kubo-rpc-client').IPFSHTTPClient} ipfs + * @returns {Promise} + */ connectionTest: async (ipfs) => { // ipfs connection is working if can we fetch the bw stats. // See: https://github.com/ipfs-shipyard/ipfs-webui/issues/835#issuecomment-466966884 diff --git a/src/bundles/retry-init.js b/src/bundles/retry-init.js index fb837e7d9..1f5d1ce06 100644 --- a/src/bundles/retry-init.js +++ b/src/bundles/retry-init.js @@ -2,17 +2,46 @@ import { createSelector } from 'redux-bundler' import { ACTIONS } from './ipfs-provider.js' /** - * @typedef {import('./ipfs-provider').Message} Message + * + * @typedef {Object} AppIdle + * @property {'APP_IDLE'} type + * + * @typedef {Object} DisableRetryInit + * @property {'RETRY_INIT_DISABLE'} type + * + * @typedef {import('./ipfs-provider').Message | AppIdle | DisableRetryInit} Message + * * @typedef {Object} Model * @property {number} [startedAt] * @property {number} [failedAt] + * @property {number} [tryCount] + * @property {boolean} [needToRetry] + * @property {number} [intervalId] + * @property {boolean} currentlyTrying * * @typedef {Object} State * @property {Model} retryInit + * + */ + +const retryTime = 2500 +const maxRetries = 5 + +/** + * @returns {Model} + */ +const initialState = () => ({ tryCount: 0, needToRetry: true, startedAt: undefined, failedAt: undefined, currentlyTrying: false }) + +/** + * @returns {Model} */ +const disabledState = () => { + return ({ ...initialState(), needToRetry: false }) +} // We ask for the stats every few seconds, so that gives a good indication // that ipfs things are working (or not), without additional polling of the api. + const retryInit = { name: 'retryInit', @@ -21,19 +50,30 @@ const retryInit = { * @param {Message} action * @returns {Model} */ - reducer: (state = {}, action) => { + reducer: (state = initialState(), action) => { switch (action.type) { + case 'RETRY_INIT_DISABLE': { + return disabledState() + } case ACTIONS.IPFS_INIT: { const { task } = action switch (task.status) { case 'Init': { - return { ...state, startedAt: Date.now() } + const startedAt = Date.now() + return { + ...state, + currentlyTrying: true, + startedAt, // new init attempt, set startedAt + tryCount: (state.tryCount || 0) + 1 // increase tryCount + } } case 'Exit': { if (task.result.ok) { - return state + // things are okay, reset the state + return disabledState() } else { - return { ...state, failedAt: Date.now() } + const failedAt = Date.now() + return { ...state, failedAt, currentlyTrying: false } } } default: { @@ -48,27 +88,40 @@ const retryInit = { }, /** - * @param {State} state + * @returns {(context: import('redux-bundler').Context) => void} */ - selectInitStartedAt: state => state.retryInit.startedAt, + doDisableRetryInit: () => (context) => { + // we should emit IPFS_INIT_FAILED at this point + context.dispatch({ + type: 'RETRY_INIT_DISABLE' + }) + }, /** * @param {State} state */ - selectInitFailedAt: state => state.retryInit.failedAt, + selectRetryInitState: state => state.retryInit, + /** + * This is continuously called by the app + * @see https://reduxbundler.com/api-reference/bundle#bundle.reactx + */ reactConnectionInitRetry: createSelector( - 'selectAppTime', - 'selectInitStartedAt', - 'selectInitFailedAt', + 'selectAppTime', // this is the current time of the app.. we need this to compare against startedAt + 'selectIpfsReady', + 'selectRetryInitState', /** - * @param {number} appTime - * @param {number|void} startedAt - * @param {number|void} failedAt + * @param {number|void} appTime + * @param {boolean} ipfsReady + * @param {Model} state */ - (appTime, startedAt, failedAt) => { - if (!failedAt || failedAt < startedAt) return false - if (appTime - failedAt < 3000) return false + (appTime, ipfsReady, { failedAt, tryCount, needToRetry, currentlyTrying }) => { + if (currentlyTrying) return false // if we are currently trying, don't try again + if (!appTime) return false // This should never happen; see https://reduxbundler.com/api-reference/included-bundles#apptimebundle + if (!needToRetry) return false // we should not be retrying, so don't. + if (tryCount != null && tryCount > maxRetries) return { actionCreator: 'doDisableRetryInit' } + if (ipfsReady) return { actionCreator: 'doDisableRetryInit' } // when IPFS is ready, we don't need to retry + if (!failedAt || appTime - failedAt < retryTime) return false return { actionCreator: 'doTryInitIpfs' } } ) diff --git a/src/lib/guards.js b/src/lib/guards.js new file mode 100644 index 000000000..15ce1bb5b --- /dev/null +++ b/src/lib/guards.js @@ -0,0 +1,23 @@ +/** + * + * @param {any} value + * @param {boolean} [throwOnFalse] + * @returns {value is Function} + */ +export function isFunction (value, throwOnFalse = true) { + if (typeof value === 'function') { return true } + if (throwOnFalse) { throw new TypeError('Expected a function') } + return false +} + +/** + * + * @param {any} value + * @param {boolean} [throwOnFalse] + * @returns {value is number} + */ +export function isNumber (value, throwOnFalse = true) { + if (typeof value === 'number') { return true } + if (throwOnFalse) { throw new TypeError('Expected a number') } + return false +} diff --git a/src/lib/guards.test.js b/src/lib/guards.test.js new file mode 100644 index 000000000..5a7d6f075 --- /dev/null +++ b/src/lib/guards.test.js @@ -0,0 +1,31 @@ +import { isFunction, isNumber } from './guards.js' + +describe('lib/guards', function () { + describe('isFunction', function () { + it('should return true if the passed value is a function', function () { + expect(isFunction(() => {})).toBe(true) + }) + + it('should throw an error if the passed value is not a function', function () { + expect(() => isFunction('not a function')).toThrow(TypeError) + }) + + it('should return false if the passed value is not a function and throwOnFalse is false', function () { + expect(isFunction('not a function', false)).toBe(false) + }) + }) + + describe('isNumber', function () { + it('should return true if the passed value is a function', function () { + expect(isNumber(1)).toBe(true) + }) + + it('should throw an error if the passed value is not a function', function () { + expect(() => isNumber('not a number')).toThrow(TypeError) + }) + + it('should return false if the passed value is not a function and throwOnFalse is false', function () { + expect(isNumber('not a number', false)).toBe(false) + }) + }) +}) diff --git a/src/lib/hofs/functions.js b/src/lib/hofs/functions.js new file mode 100644 index 000000000..eba65e75f --- /dev/null +++ b/src/lib/hofs/functions.js @@ -0,0 +1,92 @@ +import { isFunction, isNumber } from '../guards.js' + +/** + * This method creates a function that invokes func once it’s called n or more times. + * @see https://youmightnotneed.com/lodash#after + * @template A + * @template R + * @param {number} times + * @param {(...args: A[]) => R} fn + * @returns {(...args: A[]) => void | R} + */ +export const after = (fn, times) => { + isFunction(fn) && isNumber(times) + let counter = 0 + /** + * @type {(...args: A[]) => void | R} + */ + return (...args) => { + counter++ + if (counter >= times) { + return fn(...args) + } + } +} + +/** + * @see https://youmightnotneed.com/lodash#once + * @template A + * @template R + * @param {(...args: A[]) => R} fn + * @returns {(...args: A[]) => R} + */ +export const once = (fn) => { + isFunction(fn) + let called = false + /** + * @type {R} + */ + let result + + /** + * @type {(...args: A[]) => R} + */ + return (...args) => { + if (!called) { + result = fn(...args) + called = true + } + return result + } +} + +/** + * @see https://youmightnotneed.com/lodash#debounce + * + * @template A + * @template R + * @param {(...args: A[]) => R} fn - The function to debounce. + * @param {number} delay - The number of milliseconds to delay. + * @param {Object} options + * @param {boolean} [options.leading] + * @returns {(...args: A[]) => void} + */ +export const debounce = (fn, delay, { leading = false } = {}) => { + isFunction(fn) && isNumber(delay) + /** + * @type {NodeJS.Timeout} + */ + let timerId + + return (...args) => { + if (!timerId && leading) { + fn(...args) + } + clearTimeout(timerId) + + timerId = setTimeout(() => fn(...args), delay) + } +} + +/** + * Call a function only once on the nth time it was called + * @template A + * @template R + * @param {number} nth - The nth time the function should be called when it is actually invoked. + * @param {(...args: A[]) => R} fn - The function to call. + * @returns {(...args: A[]) => void | R} + */ +export const onlyOnceAfter = (fn, nth) => { + isFunction(fn) && isNumber(nth) + return after(once(fn), nth) +} diff --git a/src/lib/hofs/functions.test.js b/src/lib/hofs/functions.test.js new file mode 100644 index 000000000..56fc54003 --- /dev/null +++ b/src/lib/hofs/functions.test.js @@ -0,0 +1,114 @@ +import { jest } from '@jest/globals' + +import { after, debounce, once, onlyOnceAfter } from './functions.js' + +jest.useFakeTimers() + +describe('hofFns', function () { + describe('after', function () { + it('should not call the function if the threshold has not been reached', function () { + const fn = jest.fn() + const afterFn = after(fn, 10) + + afterFn() + afterFn() + afterFn() + + expect(fn).toHaveBeenCalledTimes(0) + }) + + it('should call the function if the threshold has been reached', function () { + const fn = jest.fn() + const afterFn = after(fn, 3) + + afterFn() + afterFn() + afterFn() + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should throw an error if the passed fn is not a function', function () { + expect(() => after('not a function', 3)).toThrow(TypeError) + }) + + it('should throw an error if times is not a number', function () { + expect(() => after(jest.fn(), 'not a number')).toThrow(TypeError) + }) + }) + + describe('once', function () { + it('should call the function only once', function () { + const fn = jest.fn() + const onceFn = once(fn) + + onceFn() + onceFn() + onceFn() + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should throw an error if the passed fn is not a function', function () { + expect(() => once('not a function')).toThrow(TypeError) + }) + }) + + describe('debounce', function () { + it('should call the function only once within the given timeframe', function () { + const fn = jest.fn() + const debounceFn = debounce(fn, 100) + + debounceFn() + debounceFn() + debounceFn() + + expect(fn).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(100) + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should throw an error if the passed fn is not a function', function () { + expect(() => debounce('not a function')).toThrow(TypeError) + }) + }) + + describe('onlyOnceAfter', function () { + it('should not call the function unless n is reached', function () { + const fn = jest.fn() + const onlyOnceAfterFn = onlyOnceAfter(fn, 5) + + onlyOnceAfterFn() + onlyOnceAfterFn() + onlyOnceAfterFn() + + expect(fn).toHaveBeenCalledTimes(0) + }) + + it('should call the function only once after n+m calls', function () { + const fn = jest.fn() + const onlyOnceAfterFn = onlyOnceAfter(fn, 5) + + onlyOnceAfterFn() + onlyOnceAfterFn() + onlyOnceAfterFn() + onlyOnceAfterFn() + onlyOnceAfterFn() + onlyOnceAfterFn() + onlyOnceAfterFn() + onlyOnceAfterFn() + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should throw an error if the passed fn is not a function', function () { + expect(() => onlyOnceAfter('not a function', 2)).toThrow(TypeError) + }) + + it('should throw an error if nth is not a number', function () { + expect(() => onlyOnceAfter(jest.fn(), 'not a number')).toThrow(TypeError) + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index da2376292..6858135b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,11 +52,13 @@ "module": "esnext" }, "exclude": [ - "./src/test" + "./src/test", + "src/**/*.test.js", ], "include": [ "**/*.ts", "@types", + // "src/**/*.js", // TODO: Include all js files when typecheck passes "src/bundles/files/**/*.js", "src/bundles/analytics.js", "src/bundles/config-save.js", @@ -72,6 +74,8 @@ "src/lib/count-dirs.js", "src/lib/sort.js", "src/lib/files.js", - "src/env.js" + "src/env.js", + "src/lib/hofs/**/*.js", + "src/lib/guards.js", ] }