router.js

/**
 * @module kdns/router
 */

'use strict';

const { EventEmitter } = require('node:events');
const { Bucket } = require('./bucket');
const keys = require('./keys');
const constants = require('./constants');


class Router extends Map {

  /**
   * Contact is inserted into the routing table
   * @event module:kdns/router~Router#contact_added
   * @param {string} fingerprint - Node ID of the inserted contact
   */
  
  /**
   * Contact is evicted from the routing table
   * @event module:kdns/router~Router#contact_deleted
   * @param {string} fingerprint - Node ID of the evicted contact
   */

  /**
   * Kademlia routing table consisting of {@link module:kdns/constants~B} total
   * {@link module:kdns/bucket~Bucket}s - each holding up to {@link module:kdns/constants~K} 
   * total {@link module:kdns/contacts~Contact}s.
   * @constructor
   * @extends Map
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
   * @param {buffer} identity - Reference point for calculating distances
   */
  constructor(identity) {
    super();

    /**
     * Exposes events arising from handling protocol messages. Some of these 
     * events may require handling if they pass a {@link Protocol~HandlerResponse}
     * @property {buffer} identity - Reference key for distance calculation
     */
    this.identity = identity || keys.getRandomKeyBuffer();
    /**
     * Exposes events arising from handling protocol messages. Some of these 
     * events may require handling if they pass a {@link Protocol~HandlerResponse}
     * @property {EventEmitter} events - Router events interface
     */
    this.events = new EventEmitter();

    for (let b = 0; b < constants.B; b++) {
      this.set(b, new Bucket());
    }
  }

  /**
   * The total contacts in the routing table
   * @property {number} size
   */
  get size() {
    let contacts = 0;
    this.forEach((bucket) => contacts += bucket.length);
    return contacts;
  }

  /**
   * The total buckets in the routing table
   * @property {number} length
   */
  get length() {
    let buckets = 0;
    this.forEach(() => buckets++);
    return buckets;
  }

  /**
   * Returns the bucket index of the given node id
   * @param {string|buffer} nodeId - Node identity to get index for
   * @returns {number}
   */
  indexOf(nodeId) {
    return keys.getBucketIndex(this.identity, nodeId);
  }

  /**
   * Returns the contact object associated with the given node id
   * @param {string|buffer} nodeId - Node identity of the contact
   * @returns {module:kdns/contacts~Contact}
   */
  getContactByNodeId(nodeId) {
    nodeId = nodeId.toString('hex');

    return this.get(this.indexOf(nodeId)).get(nodeId);
  }

  /**
   * Removes the contact from the routing table given a node id
   * @param {string|buffer} nodeId - Node identity to remove
   * @fires module:kdns/router~Router#contact_deleted
   * @return {undefined}
   */
  removeContactByNodeId(nodeId) {
    nodeId = nodeId.toString('hex');

    this.events.emit('contact_deleted', nodeId);
    return this.get(this.indexOf(nodeId)).delete(nodeId);
  }

  /**
   * Adds the contact to the routing table in the proper bucket position,
   * returning the [bucketIndex, bucket, contactIndex, contact]; if the
   * returned contactIndex is -1, it indicates the bucket is full and the
   * contact was not added; kademlia implementations should PING the contact
   * at bucket.head to determine if it should be dropped before calling this
   * method again.
   * @fires module:kdns/router~Router#contact_added
   * @param {string|buffer} nodeId - Node identity to add
   * @param {module:kdns/contacts~Contact} contact - Contact information for peer
   * @returns {array}
   */
  addContactByNodeId(nodeId, contact) {
    nodeId = nodeId.toString('hex');

    const bucketIndex = this.indexOf(nodeId);
    const bucket = this.get(bucketIndex);
    const contactIndex = bucket.set(nodeId, contact);

    this.events.emit('contact_added', nodeId);
    return [bucketIndex, bucket, contactIndex, contact];
  }

  /**
   * Returns the [index, bucket] of the occupied bucket with the lowest index
   * @returns {module:kdns/bucket~Bucket}
   */
  getClosestBucket() {
    for (let [index, bucket] of this) {
      if (index < constants.B - 1 && bucket.length === 0) {
        continue;
      }
      return [index, bucket];
    }
  }

  /**
   * Returns a array of N contacts closest to the supplied key
   * @param {string|buffer} key - Key to get buckets for
   * @param {number} [n=20] - Number of results to return
   * @param {boolean} [exclusive=false] - Exclude exact matches
   * @returns {Map}
   */
  getClosestContactsToKey(key, n = constants.K, exclusive = false) {
    const bucketIndex = this.indexOf(key);
    const contactResults = new Map();

    function _addNearestFromBucket(bucket) {
      let entries = [...bucket.getClosestToKey(key, n, exclusive).entries()];

      entries.splice(0, n - contactResults.size)
        .forEach(([id, contact]) => {
          /* istanbul ignore else */
          if (contactResults.size < n) {
            contactResults.set(id, contact);
          }
        });
    }

    let ascIndex = bucketIndex;
    let descIndex = bucketIndex;

    _addNearestFromBucket(this.get(bucketIndex));

    while (contactResults.size < n && descIndex >= 0) {
      _addNearestFromBucket(this.get(descIndex--));
    }

    while (contactResults.size < n && ascIndex < constants.B) {
      _addNearestFromBucket(this.get(ascIndex++));
    }

    return contactResults;
  }

}

module.exports.Router = Router;