plugin-rolodex.js

/**
 * @module kadence/rolodex
 */

'use strict';

const ms = require('ms');
const fs = require('node:fs');
const utils = require('./utils');
const { EventEmitter } = require('node:events');


/**
 * Keeps track of seen contacts in a compact file so they can be used as
 * bootstrap nodes
 */
class RolodexPlugin extends EventEmitter {

  static get EXTERNAL_PREFIX() {
    return 'external';
  }

  static get INTERNAL_PREFIX() {
    return 'internal';
  }

  /**
   * @constructor
   * @param {KademliaNode} node
   * @param {string} peerCacheFilePath - Path to file to use for storing peers
   */
  constructor(node, peerCacheFilePath) {
    super();

    this._peerCacheFilePath = peerCacheFilePath;
    this._cache = { t: 0 };
    this.node = node;

    // When a contact is added to the routing table, cache it
    this.node.router.events.on('add', identity => {
      this.node.logger.info(`updating cached peer profile ${identity}`);
      const contact = this.node.router.getContactByNodeId(identity);
      if (contact) {
        contact.timestamp = Date.now();
        this.setExternalPeerInfo(identity, contact);
      }
    });

    // When a contact is dropped from the routing table, remove it from cache
    this.node.router.events.on('remove', identity => {
      this.node.logger.debug(`dropping cached peer profile ${identity}`);
      delete this._cache[`${RolodexPlugin.EXTERNAL_PREFIX}:${identity}`];
      delete this._cache[`${RolodexPlugin.INTERNAL_PREFIX}:${identity}`];
    });

    this._sync();
  }

  /**
   * @private
   */
  _sync() {
    const _syncRecursive = () => {
      setTimeout(() => {
        this._syncToFile().then(() => {
          _syncRecursive();
        }, (err) => {
          this.node.logger.error(`failed to write peer cache, ${err.message}`);
        });
      }, 60 * 1000);
    };

    this._syncFromFile().then(() => {
      _syncRecursive();
    }, (err) => {
      this.node.logger.error(`failed to read peer cache, ${err.message}`);
      _syncRecursive();
    });
  }

  /**
   * @private
   */
  _syncToFile() {
    return new Promise((resolve, reject) => {
      if (!this._peerCacheFilePath) {
        return reject(new Error('No peer cache path defined'));
      }

      fs.writeFile(
        this._peerCacheFilePath,
        JSON.stringify(this._cache),
        (err) => {
          if (err) {
            reject(err);
          } else {
            resolve();
          }
        }
      );
    });
  }

  /**
   * @private
   */
  _syncFromFile() {
    return new Promise((resolve, reject) => {
      if (!this._peerCacheFilePath) {
        return reject(new Error('No peer cache path defined'));
      }

      if (!fs.existsSync(this._peerCacheFilePath)) {
        fs.writeFileSync(this._peerCacheFilePath, JSON.stringify(this._cache));
      }

      fs.readFile(this._peerCacheFilePath, (err, data) => {
        if (err) {
          return reject(err);
        }

        try {
          let _diskCache = JSON.parse(data.toString());

          if (this._cache.t - _diskCache.t < ms('1m')) {
            this._cache = JSON.parse(data.toString());
          }
        } catch (err) {
          return reject(err);
        }

        resolve();
      });
    });
  }

  /**
   * Returns a list of bootstrap nodes from local profiles
   * @returns {string[]} urls
   */
  getBootstrapCandidates() {
    const candidates = [];
    return new Promise(resolve => {
      for (let key in this._cache) {
        const [prefix, identity] = key.split(':');

        /* istanbul ignore else */
        if (prefix === RolodexPlugin.EXTERNAL_PREFIX) {
          candidates.push([identity, this._cache[key]]);
        }
      }

      resolve(candidates.sort((a, b) => b[1].timestamp - a[1].timestamp)
        .map(utils.getContactURL));
    });
  }

  /**
   * Returns the external peer data for the given identity
   * @param {string} identity - Identity key for the peer
   * @returns {object}
   */
  getExternalPeerInfo(identity) {
    return new Promise((resolve, reject) => {
      const data = this._cache[`${RolodexPlugin.EXTERNAL_PREFIX}:${identity}`];
      /* istanbul ignore if */
      if (!data) {
        reject(new Error('Peer not found'));
      } else {
        resolve(data);
      }
    });
  }

  /**
   * Returns the internal peer data for the given identity
   * @param {string} identity - Identity key for the peer
   * @returns {object}
   */
  getInternalPeerInfo(identity) {
    return new Promise((resolve, reject) => {
      const data = this._cache[`${RolodexPlugin.INTERNAL_PREFIX}:${identity}`];
      /* istanbul ignore if */
      if (!data) {
        reject(new Error('Peer not found'));
      } else {
        resolve(data);
      }
    });
  }

  /**
   * Returns the external peer data for the given identity
   * @param {string} identity - Identity key for the peer
   * @param {object} data - Peer's external contact information
   * @returns {object}
   */
  setExternalPeerInfo(identity, data) {
    return new Promise((resolve) => {
      this._cache[`${RolodexPlugin.EXTERNAL_PREFIX}:${identity}`] = data;
      this._cache.t = Date.now();
      setImmediate(() => resolve(data));
    });
  }

  /**
   * Returns the internal peer data for the given identity
   * @param {string} identity - Identity key for the peer
   * @param {object} data - Our own internal peer information
   * @returns {object}
   */
  setInternalPeerInfo(identity, data) {
    return new Promise((resolve) => {
      this._cache[`${RolodexPlugin.INTERNAL_PREFIX}:${identity}`] = data;
      setImmediate(() => resolve(data));
    });
  }

}

/**
 * Registers a {@link module:kadence/rolodex~RolodexPlugin} with a
 * {@link KademliaNode}
 * @param {string} peerCacheFilePath - Path to file to use for storing peers
 */
module.exports = function(peerCacheFilePath) {
  return function(node) {
    return new RolodexPlugin(node, peerCacheFilePath);
  }
};

module.exports.RolodexPlugin = RolodexPlugin;