UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

397 lines (323 loc) 12.6 kB
'use strict'; function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } var EventEmitter = require('./EventEmitter'); var RecordCollection = require('./RecordCollection'); var ExpiringRecordCollection = require('./ExpiringRecordCollection'); var TimerContainer = require('./TimerContainer'); var Packet = require('./Packet'); var QueryRecord = require('./QueryRecord'); var sleep = require('./sleep'); var misc = require('./misc'); var filename = require('path').basename(__filename); var debug = require('./debug')('dnssd:' + filename); var ONE_SECOND = 1000; var ONE_HOUR = 60 * 60 * 1000; var counter = 0; var uniqueId = function uniqueId() { return 'id#' + ++counter; }; /** * Creates a new Query * @class * @extends EventEmitter * * A query asks for records on a given interface. Queries can be continuous * or non-continuous. Continuous queries will keep asking for records until it * gets them all. Non-continuous queries will stop after the first answer packet * it receives, whether or not that packet has answers to its questions. * * @emits 'answer' * @emits 'timeout' * * @param {NetworkInterface} intf - the interface the query will work on * @param {EventEmitter} offswitch - emitter used to shut this query down */ function Query(intf, offswitch) { EventEmitter.call(this); // id only used for figuring out logs this._id = uniqueId(); debug('Creating a new query (' + this._id + ')'); this._intf = intf; this._offswitch = offswitch; this._originals = []; this._questions = new RecordCollection(); this._knownAnswers = new ExpiringRecordCollection([], 'Query ' + this._id); this._isStopped = false; // defaults this._delay = misc.random(20, 120); this._ignoreCache = false; this._isContinuous = true; this._timeoutDelay = null; // repeated queries increasing by a factor of 2, starting at 1s apart this._next = ONE_SECOND; this._queuedPacket = null; this._timers = new TimerContainer(this); // stop on either the offswitch or an interface error intf.using(this).once('error', this.stop); offswitch.using(this).once('stop', this.stop); // remove expired records from known answer list intf.cache.using(this).on('expired', this._removeKnownAnswer); // restart query (reset delay, etc) after waking from sleep sleep.using(this).on('wake', this._restart); } Query.prototype = Object.create(EventEmitter.prototype); Query.prototype.constructor = Query; Query.prototype.setTimeout = function (timeout) { this._timeoutDelay = timeout; return this; }; Query.prototype.continuous = function (bool) { this._isContinuous = !!bool; return this; }; Query.prototype.ignoreCache = function (bool) { this._ignoreCache = !!bool; return this; }; /** * Adds questions to the query, record names/types that need an answer * * { * name: 'Record Name.whatever.local.', * qtype: 33 * } * * If qtype isn't given, the QueryRecord that gets made will default to 255/ANY * Accepts one question object or many * * @param {object|object[]} args */ Query.prototype.add = function (args) { var _this = this; var questions = Array.isArray(args) ? args : [args]; this._originals = [].concat(_toConsumableArray(questions)); questions.forEach(function (question) { _this._questions.add(new QueryRecord(question)); }); return this; }; /** * Starts querying for stuff on the interface. Only should be started * after all questions have been added. */ Query.prototype.start = function () { var _this2 = this; // Check the interface's cache for answers before making a network trip if (!this._ignoreCache) this._checkCache(); // If all of the query's questions have been answered via the cache, and no // subsequent answers are needed, stop early. if (!this._questions.size) { debug('All answers found in cache, ending early (' + this._id + ')'); this.stop(); return this; } // Only attach interface listeners now that all questions have been added and // the query has been started. Answers shouldn't be processed before the // query has been fully set up and started. this._intf.using(this).on('answer', this._onAnswer).on('query', this._onQuery); // Prepare packet early to allow for duplicate question suppression this._queuedPacket = this._makePacket(); // Only start timeout check AFTER initial delay. Otherwise it could possibly // timeout before the query has even been sent. this._timers.setLazy('next-query', function () { if (_this2._timeoutDelay) _this2._startTimer(); _this2._send(); }, this._delay); return this; }; /** * Stops the query. Has to remove any timers that might exist because of this * query, like this query's timeout, next queued timers, and also any timers * inside knownAnswers (ExpiringRecordCollections have timers too). */ Query.prototype.stop = function () { if (this._isStopped) return; debug('Query stopped (' + this._id + ')'); this._isStopped = true; this._timers.clear(); this._knownAnswers.clear(); this._intf.removeListenersCreatedBy(this); this._offswitch.removeListenersCreatedBy(this); this._intf.cache.removeListenersCreatedBy(this); sleep.removeListenersCreatedBy(this); }; /** * Resets the query. When waking from sleep the query should clear any known * answers and start asking for things again. */ Query.prototype._restart = function () { var _this3 = this; if (this._isStopped) return; debug('Just woke up, restarting query (' + this._id + ')'); this._timers.clear(); this._questions.clear(); this._knownAnswers.clear(); this._originals.forEach(function (question) { _this3._questions.add(new QueryRecord(question)); }); this._next = ONE_SECOND; this._send(); }; /** * Sends the query packet. Gets called repeatedly. * * Each packet is prepared in advance for the next scheduled sending. This way * if another query comes in from another mDNS responder with some of the same * questions as this query, those questions can be removed from this packet * before it gets sent to reduce network chatter. * * Right before the packet actually gets sent here, any known answers learned * from other responders (including those since the last outgoing query) are * added to the packet. */ Query.prototype._send = function () { debug('Sending query (' + this._id + ')'); // add known answers (with adjusted TTLs) to the outgoing packet var packet = this._addKnownAnswers(this._queuedPacket); if (!packet.isEmpty()) this._intf.send(packet);else debug('No questions to send, suppressing empty packet (' + this._id + ')'); // queue next. the packet is prepared in advance for duplicate question checks if (this._isContinuous) { this._queuedPacket = this._makePacket(); this._timers.setLazy('next-query', this._send, this._next); // each successive query doubles the delay up to one hour this._next = Math.min(this._next * 2, ONE_HOUR); } }; /** * Create query packet * * Note this doesn't add known answers. Those need to be added later as they * can change in the time between creating the packet and sending it. */ Query.prototype._makePacket = function () { var packet = new Packet(); packet.setQuestions(this._questions.toArray()); return packet; }; /** * Adds current known answers to the packet * * Known answers are shared records from other responders. They expire from * the known answer list as they get too old. Known answers are usually * (always?) shared records for questions that have multiple possible answers, * like PTRs. */ Query.prototype._addKnownAnswers = function (packet) { // only known answers whose TTL is >50% of the original should be included var knownAnswers = this._knownAnswers.getAboveTTL(0.50); // the cache-flush bit should not be set on records in known answer lists knownAnswers.forEach(function (answer) { answer.isUnique = false; }); packet.setAnswers(knownAnswers); return packet; }; /** * Old records should be removed from the known answer list as they expire */ Query.prototype._removeKnownAnswer = function (record) { if (this._knownAnswers.has(record)) { debug('Removing expired record from query\'s known answer list (%s): \n%s', this._id, record); this._knownAnswers.delete(record); } }; /** * Handles incoming answer packets from other mDNS responders * * If the incoming packet answers all remaining questions or if this query is * a 'non-continuous' query, the handler will stop the query and shut it down. * * @emits 'answer' event with * - each answer record found, and * - all the other records in the packet * * @param {packet} packet - the incoming packet */ Query.prototype._onAnswer = function (packet) { var _this4 = this; if (this._isStopped) return; var incomingRecords = [].concat(_toConsumableArray(packet.answers), _toConsumableArray(packet.additionals)); incomingRecords.forEach(function (record) { _this4._questions.forEach(function (question) { if (!record.canAnswer(question)) return; debug('Answer found in response (Query %s): \n%s', _this4._id, record); // If the answer is unique (meaning there is only one answer), don't need // to keep asking for it and the question can be removed from the pool. // If answer is a shared record (meaning there are possibly more than one // answer, like with PTR records), add it to the known answer list. if (record.isUnique) _this4._questions.delete(question);else _this4._knownAnswers.add(record); // emit answer record along with the other record that came with it _this4.emit('answer', record, incomingRecords.filter(function (r) { return r !== record; })); }); }); // Non-continuous queries get shut down after first response, answers or not. // Queries that have had all questions answered get shut down now too. if (!this._isContinuous || !this._questions.size) this.stop(); }; /** * Handles incoming queries from other responders * * This is solely used to do duplicate question suppression (7.3). If another * responder has asked the same question as one this query is about to send, * this query can suppress that question since someone already asked for it. * * Only modifies the next scheduled query packet (this._queuedPacket). * * @param {Packet} packet - the incoming query packet */ Query.prototype._onQuery = function (packet) { if (this._isStopped) return; // Make sure we don't suppress ourselves by acting on our own // packets getting fed back to us. (this handler will receive this query's // outgoing packets too as they come back in on the interface.) if (packet.isLocal()) return; // can only suppress if the known answer section is empty (see 7.3) if (packet.answers.length) return; // ignore suppression check on QU questions, only applies to QM questions var incoming = packet.questions.filter(function (q) { return q.QU === false; }); var outgoing = this._queuedPacket.questions.filter(function (q) { return q.QU === false; }); // suppress outgoing questions that also appear in incoming records var questions = new RecordCollection(outgoing).difference(incoming).toArray(); var suppressed = outgoing.filter(function (out) { return !~questions.indexOf(out); }); if (suppressed.length) { debug('Suppressing duplicate questions (%s): %r', this._id, suppressed); this._queuedPacket.setQuestions(questions); } }; /** * Check the interface's cache for valid answers to query's questions */ Query.prototype._checkCache = function () { var _this5 = this; this._questions.forEach(function (question) { var answers = _this5._intf.cache.find(question); answers.forEach(function (record) { debug('Answer found in cache (Query %s): \n%s', _this5._id, record); if (record.isUnique) _this5._questions.delete(question);else _this5._knownAnswers.add(record); _this5.emit('answer', record, answers.filter(function (a) { return a !== record; })); }); }); }; /** * Starts the optional timeout timer * @emits `timeout` if answers don't arrive in time */ Query.prototype._startTimer = function () { var _this6 = this; this._timers.set('timeout', function () { debug('Query timeout (' + _this6._id + ')'); _this6.emit('timeout'); _this6.stop(); }, this._timeoutDelay); }; module.exports = Query;