sterfive-bonjour-service
Version:
A Bonjour/Zeroconf implementation in TypeScript
204 lines (172 loc) • 7.44 kB
text/typescript
import { setTimeout } from 'timers'
import flatten from 'array-flatten'
import dnsEqual from 'dns-equal'
import * as mDNS from 'multicast-dns'
import { MulticastDNS } from 'multicast-dns'
import Server from './mdns-server'
import Service, { ServiceConfig, ServiceRecord } from './service'
const REANNOUNCE_MAX_MS: number = 60 * 60 * 1000
const REANNOUNCE_FACTOR: number = 3
export class Registry {
private server: Server
private services: Array<Service> = []
constructor(server: Server) {
this.server = server
}
public publish(config: ServiceConfig): Service {
function start(service: Service, registry: Registry, opts: Service) {
if (service.activated) return
service.activated = true
registry.services.push(service)
if (!(service instanceof Service)) return
if (opts.probe) {
registry.probe(registry.server.mdns, service, (exists: boolean) => {
if (exists) {
service.stop()
console.log(new Error('Service name is already in use on the network'))
return
}
registry.announce(registry.server, service)
})
} else {
registry.announce(registry.server, service)
}
}
function stop(service: Service, registry: Registry, callback?: CallableFunction) {
if (!service.activated) return
if (!(service instanceof Service)) return
registry.teardown(registry.server, service, callback)
const index = registry.services.indexOf(service)
if (index !== -1) registry.services.splice(index, 1)
}
let service = new Service(config)
service.start = start.bind(null, service, this)
service.stop = stop.bind(null, service, this)
service.start({ probe: config.probe !== false })
return service
}
public unpublishAll(callback: CallableFunction | undefined) {
this.teardown(this.server, this.services, callback)
this.services = []
}
public destroy() {
this.services.map((service) => {
if (service._broadCastTimeout) {
clearTimeout(service._broadCastTimeout)
service._broadCastTimeout = undefined
}
service.destroyed = true
})
}
/**
* Check if a service name is already in use on the network.
*
* Used before announcing the new service.
*
* To guard against race conditions where multiple services are started
* simultaneously on the network, wait a random amount of time (between
* 0 and 250 ms) before probing.
*
*/
private probe(mdns: MulticastDNS, service: Service, callback: CallableFunction) {
let sent: boolean = false
let retries: number = 0
let timer: NodeJS.Timer
const send = () => {
// abort if the service have or is being stopped in the meantime
if (!service.activated || service.destroyed) return
mdns.query(service.fqdn, 'ANY' as any, function () {
// This function will optionally be called with an error object. We'll
// just silently ignore it and retry as we normally would
sent = true
timer = setTimeout(++retries < 3 ? send : done, 250)
timer.unref()
})
}
const matchRR = (rr: { name: string }): boolean => {
return dnsEqual(rr.name, service.fqdn)
}
const onresponse = (packet: mDNS.ResponsePacket) => {
// Apparently conflicting Multicast DNS responses received *before*
// the first probe packet is sent MUST be silently ignored (see
// discussion of stale probe packets in RFC 6762 Section 8.2,
// "Simultaneous Probe Tiebreaking" at
// https://tools.ietf.org/html/rfc6762#section-8.2
if (!sent) return
if (packet.answers.some(matchRR) || packet.additionals.some(matchRR)) done(true)
}
const done = (exists: boolean) => {
mdns.removeListener('response', onresponse)
clearTimeout(timer)
callback(!!exists)
}
mdns.on('response', onresponse)
setTimeout(send, Math.random() * 250)
}
/**
* Initial service announcement
*
* Used to announce new services when they are first registered.
*
* Broadcasts right away, then after 3 seconds, 9 seconds, 27 seconds,
* and so on, up to a maximum interval of one hour.
*/
private announce(server: Server, service: Service) {
let delay = 1000
const packet: ServiceRecord[] = service.records()
// Register the records
server.register(packet)
const broadcast = () => {
if (!service.activated || service.destroyed) return
service._broadCastTimeout = undefined
server.mdns.respond({ answers: packet }, () => {
// This function will optionally be called with an error object. We'll
// just silently ignore it and retry as we normally would
if (!service.published) {
service.activated = true
service.published = true
service.emit('up')
}
delay = delay * REANNOUNCE_FACTOR
if (delay < REANNOUNCE_MAX_MS && !service.destroyed) {
service._broadCastTimeout = setTimeout(broadcast, delay)
}
})
}
service._broadCastTimeout = setTimeout(broadcast, 1)
}
/**
* Stop the given services
*
* Besides removing a service from the mDNS registry, a "goodbye"
* message is sent for each service to let the network know about the
* shutdown.
*/
private teardown(server: Server, serviceOrServices: Array<Service> | Service, callback: any) {
let services = Array.isArray(serviceOrServices) ? serviceOrServices : [serviceOrServices]
services = services.filter((service: Service) => service.activated) as Service[] // ignore services not currently starting or started
const records = flatten.depth(
services.map((service) => {
service.activated = false
const records = service.records()
records.forEach((record: ServiceRecord) => {
record.ttl = 0 // prepare goodbye message
})
return records
}),
1
) as unknown as ServiceRecord[]
if (records.length === 0) return callback && callback()
server.unregister(records)
// send goodbye message
server.mdns.respond(records, function () {
;(services as Array<Service>).forEach(function (service) {
service.published = false
})
if (typeof callback === 'function') {
callback.apply(null, arguments)
}
})
}
}
export default Registry