UNPKG

squeaky

Version:

a minimal nsq tcp client

321 lines (280 loc) 10 kB
'use strict' const Assert = require('assert') const { EventEmitter } = require('events') const { URL } = require('url') const { Connecting, Closing, Disconnected, Polling, Ready, Paused } = require('./states') const Connection = require('./connection') const defaults = require('./defaults') const http = require('./http') class Subscriber extends EventEmitter { constructor (options) { super() this.options = Object.assign({}, defaults.subscriber) if (typeof options === 'string') { const hosts = options.split(',') const opts = {} const first = new URL(hosts.shift()) for (const key in this.options) { let value switch (key) { case 'host': if (first.protocol === 'nsq:') { value = first.hostname } break case 'port': if (first.protocol === 'nsq:') { value = +first.port } break case 'topic': value = first.pathname.replace(/^\//, '') // coverage disabled here because tests always use ephemeral topics /* istanbul ignore next */ if (first.searchParams.has('ephemeral') || first.searchParams.has('ephemeralTopic')) { value += '#ephemeral' } break case 'channel': value = first.searchParams.get('channel') // coverage disabled here because tests always use ephemeral topics /* istanbul ignore next */ if (first.searchParams.has('ephemeral') || first.searchParams.has('ephemeralChannel')) { value += '#ephemeral' } break case 'lookup': if (first.protocol === 'nsqlookup:') { // coverage disabled so i don't have to set up ssl for tests /* istanbul ignore next */ const proto = first.searchParams.has('ssl') ? 'https' : 'http' value = [`${proto}://${first.host}`] } break case 'debug': break default: if (first.searchParams.has(key)) { value = first.searchParams.get(key) // skipping coverage because right now we only have numerical options // but we may have something else in the future and i don't want to // footgun accidentally so this guard stays /* istanbul ignore next */ if (typeof defaults.publisher[key] === 'number') { value = +value } } } if (value) { opts[key] = value } } // coverage disabled because i didn't feel like setting up two phony lookupds in tests /* istanbul ignore next */ if (hosts.length && opts.lookup) { for (const host of hosts) { const parsed = new URL(host) if (parsed.protocol === 'nsqlookup:') { const proto = first.searchParams.has('ssl') ? 'https' : 'http' opts.lookup.push(`${proto}://${parsed.host}`) } } } Object.assign(this.options, opts) } else { Object.assign(this.options, options) } if (!Array.isArray(this.options.lookup)) { this.options.lookup = this.options.lookup ? [].concat(this.options.lookup) : [] } this.debug = this.options.debug Assert(this.options.topic, 'Must specify a topic') Assert(this.options.channel, 'Must specify a channel') this.connections = new Map() this.state = Disconnected this.on('newListener', async (event) => { if (!this._started && event === 'message') { if (this.state !== Ready) { if (this.lookups && this.lookups.length) { await new Promise((resolve) => this.once('pollComplete', resolve)) } else { await new Promise((resolve) => this.once('ready', resolve)) } } this._started = true await this._distributeReady() } }) if (this.options.autoConnect) { this.connect() } } async connect () { if (this.state !== Disconnected) { return Promise.reject(new Error('A connection has already been established')) } this.state = Connecting if (this.options.lookup.length) { this.lookups = this.options.lookup.map(url => !url.startsWith('http') ? `http://${url}` : url) this._poll() await new Promise((resolve) => this.once('pollComplete', resolve)) } else { const { host, port } = this.options this.connections.set(`${host}:${port}`, this._createConnection(host, port)) await new Promise((resolve) => this.once('ready', resolve)) } if (this._started) { await this._distributeReady() } } async pause () { if (this.state !== Ready) { throw new Error('Must be ready in order to pause') } this.debug('pausing') this.state = Paused await this._distributeReady() } async resume () { if (this.state !== Paused) { throw new Error('Must be paused in order to resume') } this.debug('unpausing') this.state = Ready await this._distributeReady() } _createConnection (host, port) { const options = Object.assign({}, this.options, { host, port }) const connection = new Connection(options) connection.on('error', (err) => this.emit('error', Object.assign(err, { host, port }))) connection.on('close', () => this.emit('close', { host, port })) connection.on('ready', () => { this.emit('ready', { host, port }) if (!this.options.lookup.length) { this.state = Ready } }) connection.on('connect', () => this.emit('connect', { host, port })) connection.on('reconnect', () => this.emit('reconnect', { host, port })) connection.on('disconnect', () => this.emit('disconnect', { host, port })) connection.on('drain', () => this.emit('drain', { host, port })) connection.on('message', (msg) => { connection._lastMessage = Date.now() this.emit('message', msg) }) connection.subscribe(this.options.topic, this.options.channel) // don't cover unref /* istanbul ignore next */ if (this._unref) { connection.unref() } return connection } async _poll () { this.state = Polling this.emit('pollBegin') this.debug('starting poll') const desired = new Set() this.debug('asking lookups for hosts') for (const lookup of this.lookups) { try { const res = await http.get(`${lookup}/lookup?topic=${this.options.topic}`) for (const producer of res.producers) { const host = producer.broadcast_address const port = producer.tcp_port desired.add(`${host}:${port}`) } } catch (err) { this.emit('warn', Object.assign(err, { code: 'ELOOKUPERROR', host: lookup })) } } this.debug('determining which connections to remove') for (const [name, connection] of this.connections) { if (!desired.has(name)) { await connection.close() this.connections.delete(name) const port = Number(name.split(':').pop()) const host = name.slice(0, name.lastIndexOf(':')) this.emit('removed', { host, port }) } } this.debug('determining new connections to be created') for (const name of desired) { if (!this.connections.has(name)) { const port = Number(name.split(':').pop()) const host = name.slice(0, name.lastIndexOf(':')) this.connections.set(name, this._createConnection(host, port)) } } this.debug('scheduling next poll run') clearTimeout(this._pollTimer) this._pollTimer = setTimeout(() => { this._poll() }, this.options.discoverFrequency) this.debug('poll finished') this.state = Ready this.emit('pollComplete') if (this._started) { await this._distributeReady() } } async _distributeReady () { this.emit('distributeBegin') if (this.state === Paused) { for (const connection of this.connections.values()) { if (connection._ready > 0) { await connection.ready(0) } } } else if (this.options.concurrency < this.connections.size) { let unused = [] let used = [] for (const connection of this.connections.values()) { if (connection._ready > 0) { used.push(connection) } else { unused.push(connection) } } unused = unused.sort((a, b) => a._lastMessage - b._lastMessage).slice(0, this.options.concurrency) // coverage disabled here because Array.prototype.sort doesn't run unless length > 1 // and i'm not inclined to force having two nsq servers available for tests /* istanbul ignore next */ used = used.sort((a, b) => a._lastMessage - b._lastMessage).slice(0, unused.length) for (const connection of used) { await connection.ready(0) } for (const connection of unused) { await connection.ready(1) } } else { for (const connection of this.connections.values()) { await connection.ready(Math.floor(this.options.concurrency / this.connections.size)) } } this.emit('distributeComplete') } async close () { if (this.state === Polling) { await new Promise((resolve) => this.once('pollComplete', resolve)) } clearTimeout(this._pollTimer) this.state = Closing for (const [name, connection] of this.connections) { await connection.close() this.connections.delete(name) const port = Number(name.split(':').pop()) const host = name.slice(0, name.lastIndexOf(':')) this.emit('removed', { host, port }) } } // don't cover unref /* istanbul ignore next */ unref () { this._unref = true for (const connection of this.connections.values()) { connection.unref() } } } module.exports = Subscriber