Home Reference Source Repository

lib/index.js

import { decode } from 'jwt-simple'
import { assert } from 'chai'
import { createLogger } from 'bunyan'


const IS_DEV = process.env.NODE_ENV !== 'production'
const CODES = { DELAY: 'DELAY'
              , DELAY_ERROR: 'DELAY_ERROR'
              , INVALID_JWT: 'INVALID_JWT'
              , EXECUTE: 'EXECUTE'
              , SCHEDULE: 'SCHEDULE'
              , START: 'START'
              , CANCEL: 'CANCEL'
              }
const format = (code, message) => `${code}|${message}`

const validate = ({ refresh, leadSeconds, log = createLogger({ name: 'autorefresh', level: IS_DEV ? 'warn' : 'error' })}) => {
  if(IS_DEV) {
    assert.ok(refresh, 'autorefresh requires a refresh function parameter')
    assert.ok(leadSeconds, 'autorefresh requires a leadSeconds number or function returning a number in seconds parameter')
    assert.typeOf(refresh, 'function', 'autorefresh refresh parameter must be a function')
    assert(['number', 'function'].includes(typeof leadSeconds), 'function', 'autorefresh refresh parameter must be a function')
  }
  return { refresh, leadSeconds, log }
}

export default function autorefresh(opts) {
  const { refresh, leadSeconds, log } = validate(opts)
  let timeoutID = null

  const calculateDelay = access_token => {
    try {
      if(IS_DEV) {
        assert.ok(access_token, 'calculateDelay expects an access_token parameter')
        assert.typeOf(access_token, 'string', 'access_token should be a string')
      }
      const { exp, nbf } = decode(access_token, null, true)
      if(IS_DEV) {
        assert.ok(exp, 'autorefresh requires JWT token with "exp" standard claim')
        if(nbf) {
          assert.typeOf(nbf, 'number', 'nbf claim should be a future NumericDate value')
          assert.isBelow(nbf, exp, '"nbf" claim should be less than "exp" claim if it exists')
        }
      }
      const lead = typeof leadSeconds === 'function' ? leadSeconds() : leadSeconds
      if(IS_DEV) {
        assert.typeOf(lead, 'number', 'leadSeconds must be or return a number')
        assert.isAbove(lead, 0, 'lead seconds must resolve to a positive number of seconds')
      }
      const refreshAtMS = (exp - lead) * 1000
      const delay = refreshAtMS - Date.now()
      log.info(format(CODES.DELAY, `calculated autorefresh delay => ${(delay / 1000).toFixed(1)} seconds`))
      return delay
    } catch(err) {
      if(/$Unexpected token [A-Za-z] in JSON/.test(err.message))
        throw new Error(format(CODES.INVALID_JWT, `JWT token was not a valid format => ${access_token}`))
      throw new Error(format(CODES.DELAY_ERROR, `error occurred calculating autorefresh delay => ${err.message}`))
    }
  }

  const _schedule = access_token => {
    if(IS_DEV) assert.typeOf(access_token, 'string', '_schedule expects a string access_token parameter')
    const delay = calculateDelay(access_token)
    if(IS_DEV) assert.isAbove(delay, 0, 'next auto refresh should always be in the future')
    return schedule(delay)
  }

  const execute = () => {
    clearTimeout(timeoutID)
    log.info(format(CODES.EXECUTE, 'executing refresh'))
    const result = refresh()
    if(typeof result === 'string')
      return _schedule(result)
    assert.ok(result.then, 'refresh must return the access_token or a string that resolves to the access_token')
    return result
      .then(access_token => _schedule(access_token))
      .catch(err => {
        log.error(err, format(CODES.INVALID_REFRESH, `refresh rejected with an error => ${err.message}`))
        throw err
      })
  }

  const schedule = delay => {
    clearTimeout(timeoutID)
    log.info(format(CODES.SCHEDULE, `scheduled refresh in ${(delay / 1000).toFixed(1)} seconds`))
    timeoutID = setTimeout(() => execute(), delay)
  }

  const start = access_token => {
    log.info(format(CODES.START, 'autorefresh started'))
    let delay = calculateDelay(access_token)
    if(IS_DEV) assert.typeOf(delay, 'number', 'calculateDelay must return a number in milliseconds')
    if(delay > 0) schedule(delay)
    else execute()
    const stop = () => {
      clearTimeout(timeoutID)
      log.info(format(CODES.CANCEL, 'autorefresh cancelled'))
    }
    return stop
  }
  return start
}