UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

332 lines (258 loc) 9.36 kB
const EventEmitter = require('./EventEmitter'); const TimerContainer = require('./TimerContainer'); const filename = require('path').basename(__filename); const debug = require('./debug')(`dnssd:${filename}`); const ONE_SECOND = 1000; /** * @class * @extends EventEmitter * * ExpiringRecordCollection is a set collection for resource records or * query records. Uniqueness is determined by a record's hash property, * which is a hash of a records name, type, class, and rdata. Records * are evicted from the collection as their TTLs expire. * * Since there may be several records with the same name, type, and class, * but different rdata, within a record set (e.g. PTR records for a service * type), related records are tracked in this._related. * * This collection emits 'reissue' and 'expired' events as records TTLs * decrease towards expiration. Reissues are emitted at 80%, 85%, 90% and 95% * of each records TTL. Re-adding a record refreshes the TTL. * * @emits 'expired' * @emits 'reissue' */ class ExpiringRecordCollection extends EventEmitter { /** * @param {ResourceRecord[]} [records] - optional starting records * @param {string} [description] - optional description for debugging */ constructor(records, description) { super(); // make debugging easier, who owns this / what it is this._desc = description; this._records = {}; // record.hash: record this._related = {}; // record.namehash: Set() of record hashes this._insertionTime = {}; // record.hash: Date.now() this._timerContainers = {}; // record.hash: new TimerContainer() this.size = 0; if (records) this.addEach(records); } /** * Adds record. Re-added records refresh TTL expiration timers. * @param {ResourceRecord} record */ add(record) { const id = record.hash; const group = record.namehash; // expire TTL=0 goodbye records instead if (record.ttl === 0) return this.setToExpire(record); debug.v('#add(): %s', record); debug.v(` to: ${this._desc}`); // only increment size if the record is new if (!this._records[id]) this.size++; // keep track of related records (same name, type, and class) if (!this._related[group]) this._related[group] = new Set(); // remove any old timers if (this._timerContainers[id]) this._timerContainers[id].clear(); this._records[id] = record; this._related[group].add(id); this._insertionTime[id] = Date.now(); this._timerContainers[id] = new TimerContainer(); // do reissue/expired timers this._schedule(record); } addEach(records) { records.forEach(record => this.add(record)); } has(record) { return Object.hasOwnProperty.call(this._records, record.hash); } /** * Checks if a record was added to the collection within a given range * * @param {ResourceRecord} record * @param {number} range - in *seconds* * @return {boolean} */ hasAddedWithin(record, range) { const then = this._insertionTime[record.hash]; return Number(parseFloat(then)) === then && (range * ONE_SECOND) >= (Date.now() - then); } /** * Returns a *clone* of originally added record that matches requested record. * The clone's TTL is reduced to the current TTL. A clone is used so the * original record's TTL isn't modified. * * @param {ResourceRecord} record * @return {ResourceRecord|undefined} */ get(record) { if (!this.has(record)) return undefined; const then = this._insertionTime[record.hash]; const elapsed = ~~((Date.now() - then) / ONE_SECOND); const clone = record.clone(); clone.ttl -= elapsed; return clone; } /** * @emits 'expired' w/ the expiring record */ delete(record) { if (!this.has(record)) return; const id = record.hash; const group = record.namehash; this.size--; this._timerContainers[id].clear(); delete this._records[id]; delete this._insertionTime[id]; delete this._timerContainers[id]; if (this._related[group]) this._related[group].delete(id); debug.v('deleting: %s', record); debug.v(` from: ${this._desc}`); this.emit('expired', record); } /** * Deletes all records, clears all timers, resets size to 0 */ clear() { debug.v('#clear()'); this.removeAllListeners(); Object.values(this._timerContainers).forEach(timers => timers.clear()); this.size = 0; this._records = {}; this._related = {}; this._insertionTime = {}; this._timerContainers = {}; } /** * Sets record to be deleted in 1s, but doesn't immediately delete it */ setToExpire(record) { // can't expire unknown records if (!this.has(record)) return; // don't reset expire timer if this gets called again, say due to // repeated goodbyes. only one timer (expire) would be set in this case if (this._timerContainers[record.hash].count() === 1) return; debug.v('#setToExpire(): %s', record); debug.v(` on: ${this._desc}`); this._timerContainers[record.hash].clear(); this._timerContainers[record.hash].set(() => this.delete(record), ONE_SECOND); } /** * Flushes any other records that have the same name, class, and type * from the collection *if* the records have been in the collection * longer than 1s. */ flushRelated(record) { // only flush records that have cache-flush bit set if (!record.isUnique) return; this._getRelatedRecords(record.namehash).forEach((related) => { // can't flush itself if (related.equals(record)) return; // only flush records added more than 1s ago if (!this.hasAddedWithin(related, 1)) this.setToExpire(related); }); } /** * Records with original TTLs (not reduced ttl clones) */ toArray() { return Object.values(this._records); } /** * Checks if collection contains any other records with the same name, type, * and class but different rdata. Non-unique records always return false & a * record can't conflict with itself * * @param {ResourceRecord} record * @return {boolean} */ hasConflictWith(record) { if (!record.isUnique) return false; return !!this._getRelatedRecords(record.namehash) .filter(related => !related.equals(record)) .length; } /** * Finds any records in collection that matches name, type, and class of a * given query. Rejects any records with a TTL below the cutoff percentage. * Returns clones of records to prevent changes to original objects. * * @param {QueryRecord} query * @param {number} [cutoff] - percentage, 0.0 - 1.0 * @return {ResourceRecords[]} */ find(query, cutoff = 0.25) { debug.v(`#find(): "${query.name}" type: ${query.qtype}`); debug.v(` in: ${this._desc}`); return this._filterTTL(this._getRelatedRecords(query.namehash), cutoff); } /** * Gets all any records in collection with a TTL above the cutoff percentage. * Returns clones of records to prevent changes to original objects. * * @param {number} [cutoff] - percentage, 0.0 - 1.0 * @return {ResouceRecords[]} */ getAboveTTL(cutoff = 0.25) { debug.v(`#getAboveTTL(): %${cutoff * 100}`); return this._filterTTL(this.toArray(), cutoff); } /** * Gets records that have same name, type, and class. */ _getRelatedRecords(namehash) { return (this._related[namehash] && this._related[namehash].size) ? [...this._related[namehash]].map(id => this._records[id]) : []; } /** * Filters given records by their TTL. * Returns clones of records to prevent changes to original objects. * * @param {ResouceRecords[]} records * @param {number} cutoff - percentage, 0.0 - 1.0 * @return {ResouceRecords[]} */ _filterTTL(records, cutoff) { return records.reduce((result, record) => { const then = this._insertionTime[record.hash]; const elapsed = ~~((Date.now() - then) / ONE_SECOND); const percent = (record.ttl - elapsed) / record.ttl; debug.v('└── %s @ %d%', record, ~~(percent*100)); if (percent >= cutoff) { const clone = record.clone(); clone.ttl -= elapsed; result.push(clone); } return result; }, []); } /** * Sets expiration/reissue timers for a record. * * Sets expiration at end of TTL. * Sets reissue events at 80%, 85%, 90%, 95% of records TTL, plus a random * extra 0-2%. (see rfc) * * @emits 'reissue' w/ the record that needs to be refreshed * * @param {ResouceRecords} record */ _schedule(record) { const id = record.hash; const ttl = record.ttl * ONE_SECOND; const expired = () => this.delete(record); const reissue = () => this.emit('reissue', record); const random = (min, max) => Math.random() * (max - min) + min; this._timerContainers[id].setLazy(reissue, ttl * random(0.80, 0.82)); this._timerContainers[id].setLazy(reissue, ttl * random(0.85, 0.87)); this._timerContainers[id].setLazy(reissue, ttl * random(0.90, 0.92)); this._timerContainers[id].setLazy(reissue, ttl * random(0.95, 0.97)); this._timerContainers[id].set(expired, ttl); } } module.exports = ExpiringRecordCollection;