url-probe.js

import { StateLog } from './state-log.js';
import { logger } from 'log-instance';

/**
 * Probe a single URL and log the JSON results
 */
export class UrlProbe {
  /**
   * Build a UrlProbe
   * @param {UrlProbeOptions} opts
   * @param {URL} opts.url - REQUIRED: URL to probe
   * @param {StateLog} opts.stateLog - REQUIRED: probe result log 
   * @param {object} opts.jsonFilter - state normalization properties (undefined)
   * @param {String} opts.type - client type (heartbeat)
   */
  constructor(opts={}) {
    const msg = 'UrlProbe.ctor()';
    let { 
      url, 
      jsonFilter, 
      type = "heartbeat",
      stateLog, 
    } = opts;

    try {
      new URL(url);
    } catch(e) {
      throw new Error(`${msg} Invalid url: ${url}`);
    }
    if (stateLog == null) {
      throw new Error(`${msg} stateLog is required`);
    }

    Object.assign(this, {url, type, jsonFilter, stateLog });
  }

  /**
   * fetch with timeout
   * @param {URL} url - as for fetch()
   * @param {fetchOptions} opts - as for fetch()
   * @param {milliseconds} opts.timeout - required timeout (1000)
   */
  static async fetch(url, opts={}) {
    const msg = 'UrlProbe.fetch()';
    let { signal, timeout = 1000 } = opts;
    let fetchOpts = Object.assign({}, opts);
    let res;

    try {
      if (signal == null) {
        fetchOpts.signal = AbortSignal.timeout( timeout );
        delete fetchOpts.timeout;
      }
      res = await fetch(url, fetchOpts)
    } catch(e){
      logger.warn(msg, url, '=>', e.message);
      throw e;
    }

    return res;
  }

  /**
   * Normalize given state for storage.
   * Normalization can remove unwanted properties.
   * Normalization can also trim property values. 
   * @param {serializable} state - raw state to be normalized
   * @param {StateLogProperties} properties - normalization parameters. 
   *
   * @returns {serializable} normalized object as defined by:
   * * If properties.xyz is true, that property is included 
   * in normalized state.
   * * If properties.xyz is a string, 
   * the string is converted to a regular expression and
   * the state value of that property is stripped of 
   * anything that does not match that regular expression.
   * * If properties.xyz is anything else, an Error is thrown.
   * * If properties is null, the entire state is left as is.
   */
  static normalizeState(state, properties) {
    const msg = `UrlProbe.normalizeState()`;

    if (properties == null) {
      return state; 
    }

    if (typeof state === "object") {
      let normalized = {};
      Object.entries(properties).forEach(entry=>{
        let [ key, value ] = entry;
        let stateValue = state[key];

        if (value === true) {
          normalized[key] = stateValue;
        } else if (typeof value === 'string') {
          let re = new RegExp(value);
          if (stateValue != null) {
            let match = stateValue.match(re);
            normalized[key] = match ? match[0] : 'no-match';
          }
        } else {
          let emsg = `${msg} cannot normalize "${key}"`;
          console.log({entry, value, key, normalized});
          throw new Error(emsg);
        }
      });
      state = normalized;
    }
    return state;
  }

  /**
   * Probe the URL once and log the result for the given timestamp
   * in the StateLog of the probe.
   * 
   * @param {Date} date - logging date (vs. result date)
   */
  async probe(date = new Date()) {
    const msg = 'UrlProbe.probe()';
    let { jsonFilter, stateLog, url } = this;
    let errorHandler = (e)=>{
      let state = {
        error: e.message,
        message: `${msg} could not fetch json`,
      }
      stateLog.update(state, date);
    }

    let res;
    try {
      res = await UrlProbe.fetch(url);
      let { status } = res;
      if (jsonFilter !== undefined) {
        let json = await res.json();
        try {
          json = UrlProbe.normalizeState(json, jsonFilter);
          let state = { status, json, };
          stateLog.update(state, date);
        } catch(e) {
          errorHandler(e);
        }
      } else {
        let state = { status };
        stateLog.update(state, date);
      }
    } catch (e) {
      errorHandler(e);
    }

    return res;
  }
    
}