coroutines.js

/**
 *<p>
 * A coroutine to be run during the gaps in other processing and animation.
 *</p>
 * <p>
 * The coroutine should <code>yield</code> regularly to do a time check.  A plain <code>yield</code> will cause
 * a check against the standard time remaining specified when running.  <code>yield {number}</code> will
 * check that <code>number</code> milliseconds are available and <code>yield true</code> will abandon any more
 * processing on the current frame.
 *</p>
 * @callback Coroutine
 * @generator
 * @yields {number} either undefined to perform a standard time remaining check, a number of milliseconds required for the next step or true if we should abandon the current frame
 * @returns the result of the function if any to be returned to the caller
 */

/**
 * @typedef IteratorResult
 * @object
 * @property {any} [value] - the returned value
 * @property {boolean} done - whether the iterator is complete
 */

/**
 * @interface Iterator
 */

/**
 * Get the next value
 * @function
 * @name Iterator#next
 * @param {any} value - value to send to the coroutine
 * @returns {IteratorResult}
 */

/**
 * A coroutine to be used in high priority to animate.
 *
 * Executing a <code>yield</code> will cause the routine to resume at the start
 * of the next frame.
 * @callback AnimationCoroutine
 * @generator
 * @returns the result of the function if any to be returned to the caller
 */

import {getCallback, getNodeCallback} from './polyfill'

let request = typeof window === 'undefined' ? getNodeCallback() : window.requestIdleCallback

/**
 * Call with true to use the polyfilled version of
 * the idle callback, can be more stable in certain
 * circumstances
 * @param {Boolean} internal
 */
export function useInternalEngine(internal) {
    request = internal ? getCallback() : request
}

/**
 * <p>
 *     Starts an idle time coroutine and returns a promise for its completion and
 *      any value it might return.
 * </p>
 * <p>
 *     You may pass a coroutine function or the result of calling such a function.  The
 *     latter helps when you must provide parameters to the coroutine.
 * </p>
 * @param {Coroutine|Iterator|Generator<*, *, *>} coroutine the routine to run or an iterator for an already started coroutine
 * @param {number} [loopWhileMsRemains=2 (ms)] - if less than the specified number of milliseconds remain the coroutine will continue in the next idle frame
 * @param {number} [timeout=160 (ms)] - the number of milliseconds before the coroutine will run even if the system is not idle
 * @returns {Promise<any>} the result of the coroutine
 * <strong>The promise returned by <code>run</code> has a <code>terminate()</code> method
 * that can be used to stop the routine.</strong>
 * @example
 * async function process() {
 *     let answer = await run(function * () {
 *         let total = 0
 *         for(let i=1; i < 10000000; i++) {
 *            total += i
 *            if((i % 100) === 0) yield
 *         }
 *         return total
 *     })
 *     ...
 * }
 *
 * // Or
 *
 * async function process(param) {
 *     let answer = await run(someCoroutine(param))
 * }
 */
export function run(coroutine, loopWhileMsRemains = 1, timeout = 32 * 10) {
    let terminated = false
    let resolver = null
    const result = new Promise( function (resolve, reject) {
        resolver = resolve
        const iterator = coroutine.next ? coroutine : coroutine()
        // Request a callback during idle
        request(run)
        // Handle background processing when tab is not active
        let id = setTimeout(runFromTimeout, timeout)
        let parameter = undefined

        async function run(api) {
            clearTimeout(id)
            // Stop the timeout version
            if (terminated) {
                iterator.return()
                return
            }
            let minTime = Math.max(0.5, loopWhileMsRemains)
            try {
                do {
                    const {value, done} = iterator.next(await parameter)
                    parameter = undefined
                    if (done) {
                        resolve(value)
                        return
                    }
                    if (value === true) {
                        break
                    } else if (typeof value === 'number') {
                        minTime = +value
                        if (isNaN(minTime)) minTime = 1
                    } else if (value && value.then) {
                        parameter = value
                    }
                } while (api.timeRemaining() > minTime)
            } catch (e) {
                reject(e)
                return
            }
            // Request an idle callback
            request(run)
            // Request again on timeout
            id = setTimeout(runFromTimeout, timeout)
        }

        function runFromTimeout() {
            const budget = 8.5
            const start = performance.now()
            run({
                timeRemaining() {
                    return budget - (performance.now() - start)
                },
            })
        }
    })

    result.terminate = function (result) {
        terminated = true
        if (resolver) {
            resolver(result)
        }
    }
    return result
}


let requested = false
let animationCallbacks = []

function nextAnimationFrame(fn) {
    if(typeof window === 'undefined') throw new Error("Cannot run without a browser")
    if(animationCallbacks.length === 0 && !requested) {
        requested = true
        requestAnimationFrame(process)
    }
    animationCallbacks.push(fn)
}

function process() {
    let callbacks = animationCallbacks
    if (callbacks.length) {
        requestAnimationFrame(process)
    } else {
        requested = false
    }
    animationCallbacks = []
    for (let callback of callbacks) {
        callback()
    }
}

/**
 * Start an animation coroutine, the animation will continue until
 * you return and will be broken up between frames by using a
 * <code>yield</code>.
 *
 * @param {AnimationCoroutine|Iterator} coroutine - The animation to run
 * @param {...*} [params] - Parameters to be passed to the animation function
 * @returns {Promise<any>} a value that will be returned to the caller
 * when the animation is complete.
 * <strong>The promise returned by <code>update</code> has a <code>terminate()</code> method
 * that can be used to stop the routine.</strong>
 */
export function update(coroutine, ...params) {
    if(typeof window === 'undefined') throw new Error("Requires a browser to run")
    let terminated = false
    let resolver = null
    const result = new Promise(function (resolve, reject) {
        resolver = resolve
        const iterator = coroutine.next ? coroutine : coroutine(...params)
        nextAnimationFrame(run)

        function run() {
            if (terminated) {
                iterator.return()
                return
            }

            try {
                const {value, done} = iterator.next()
                if (done) {
                    resolve(value)
                    return
                }
            } catch (e) {
                reject(e)
                return
            }

            nextAnimationFrame(run)
        }
    })
    result.terminate = function (result) {
        terminated = true
        if (resolver) {
            resolver(result)
        }
    }
    return result
}


/**
 * @callback GeneratorFunction
 * @generator
 * @param {...*} params - the parameters to pass
 * @returns {*} the result of the coroutine
 */

/**
 * @callback AsyncFunction
 * @param {*} params - the parameters to pass
 * @async
 * @returns {*} result of calling the function
 */

/**
 * Create a function that executes a pipeline of
 * functions asynchronously
 * @param {...(Function|Promise|Array<(Promise|Function|GeneratorFunction|AsyncFunction)>|GeneratorFunction|AsyncFunction)} fns - the pipeline to execute
 * @returns {AsyncFunction} an async function to execute the pipeline
 */
export function pipe(...fns) {
    return async function(params) {
        let result = params
        for(let fn of fns.flat(Infinity)) {
            if(!fn) continue
            let nextResult = fn.call(this, result)
            if(nextResult) {
                if(nextResult.next) {
                    result = await run(nextResult)
                } else if(nextResult.then) {
                    result = await nextResult
                } else {
                    result = nextResult
                }
            }
        }
        return result
    }
}

/**
 * Tap into a pipeline to call a function that will probably
 * perform side effects but should not modify the result, its
 * return value is ignored
 * @param {Function} fn - a function to be called at this point in
 * the pipeline
 * @returns {AsyncFunction} returning the passed in parameters
 */
export function tap(fn) {
    return async function(params) {
        let result = fn.call(this, params)
        if (result) {
            if (result.next) {
                await run(result)
            } else if (result.then) {
                await result
            }
        }
        return params
    }
}

/**
 * Branches a pipeline by starting another "continuation" with
 * the current parameters.  Starts a function but the pipeline
 * continues immediately creating two execution contexts
 * @param {Function} fn - the function to start - can be async or generator
 */
export function branch(fn) {
    return function (params) {
        let result = fn.call(this, params)
        if (result) {
            if (result.next) {
                run(result).catch(console.error)
            } else if (result.then) {
                result.catch(console.error)
            }
        }
        return params
    }
}

/**
 * Create a version of a function with its end
 * parameters supplied
 * @param {Function|GeneratorFunction|AsyncFunction} fn - the function to configure
 * @param {...any[]} config - the additional parameters to pass
 * @returns {Function}
 */
export function call(fn, ...config) {
    return function(...params) {
        return fn.apply(this, [...params, ...config])
    }
}

/**
 * Create a function that repeats a function multiple times
 * passing the output of each iteration as the input to the next
 * @param {Function} fn - the function to repeat
 * @param {Number} times - the number of times to repeat
 * @returns {AsyncFunction} - a async function that repeats the operation
 */
export function repeat(fn, times) {
    return async function(params) {
        let result = params
        for(let i = 0; i < times; i++) {
            result = fn.call(this, result)
            if (result.next) {
                result = await run(result)
            } else if (result.then) {
                result = await result
            }
        }
        return result
    }
}

export default run

/**
 * Creates a singleton executor of a generator function.
 * If the function is currently running it will be
 * terminated with the defaultValue and a new one started.
 *
 * This would often be used with a UI to cancel a previous calculation
 * and begin updates on a new one.
 *
 * @param {Function} fn - the generator function to wrap
 * @param {any} [defaultValue] - a value to be returned if the current execution is
 * terminated by a new one starting
 * @returns {function(...[*]): Promise<any>} a function to execute the
 * generator and return the value
 * @example
 *
 * const job = singleton(function * (array, value) {
 *      let output = []
 *      for(let item of array) {
 *         if(output.length % 100 === 0) yield
 *         output.push(complexCalculation(array, value))
 *      }
 *      return output
 * }, [])
 *
 * function doSomething(array) {
 *     job(array, 2002).then(console.log)
 * }
 *
 * doSomething(bigArray)
 * doSomething(otherArray) // -> console.log([]) from first one
 *
 */
export function singleton(fn, defaultValue) {
    let promise = null
    let extraPromises = []
    let result = (...params)=>{
        if(promise) {
            extraPromises.forEach(p=>p.terminate())
            extraPromises = []
            promise.terminate(defaultValue)
        }
        return promise = result._promise = run(fn(...params))
    }
    result.join = function(promise) {
        extraPromises.push(promise)
        return promise
    }
    return result
}