npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mdns-local

v0.1.0

Published

Pure JavaScript mDNS/DNS-SD for Node.js - service discovery and advertising for local networks. Callback-based, dual-stack IPv4/IPv6, RFC 6762/6763 compliant. No native bindings.

Readme

mdns-local

Pure JavaScript mDNS/DNS-SD for Node.js. Service discovery and advertising on local networks.

No native bindings. No Bonjour SDK. No Avahi. Zero dependencies.

Implements RFC 6762 (Multicast DNS) + RFC 6763 (DNS-Based Service Discovery).

import { MDNS } from 'mdns-local'

const mdns = new MDNS()

mdns.on('ready', () => {
  // Announce a service
  mdns.advertise({ name: 'My Server', type: '_http._tcp', port: 3000 })

  // Discover others
  const browser = mdns.discover({ type: '_http._tcp' })
  browser.on('up', (svc) => console.log('found:', svc.name, svc.host, svc.port))
})

Table of Contents

What is mDNS?

mDNS ("Multicast DNS") is how devices on a local network find each other without a central DNS server. Your Mac finds your printer, your iPhone finds your AirPlay speaker, your Raspberry Pi appears as raspberrypi.local — all of that runs on mDNS.

There are two things you can do with it:

  • Advertise — tell the network "here I am, I offer this service on this port"
  • Discover — ask the network "who has this service?" and get answers

You can also just claim a hostname (myhost.local) so other devices can reach you by name instead of IP.

Everything runs on UDP multicast to 224.0.0.251:5353. No configuration. No servers. It just works on any LAN.

Why this library?

Every other pure-JS mDNS package on npm is either abandoned, incomplete, or implements the RFC incorrectly. The native mdns binding needs the Bonjour SDK and platform-specific build tools. mdns-local is a fresh, dependency-free, callback-style implementation of RFC 6762 (mDNS) + RFC 6763 (DNS-SD), including the parts most libraries skip: probing, tiebreaking, known-answer suppression, conflict handling, and topology-change re-probing.

See Comparison below for a feature-by-feature breakdown against the other packages.

Install

npm install mdns-local

No native compilation. Runs on Linux, macOS, Windows — anywhere Node.js runs.

Quick Start

Advertise a service

Tell the network your Node.js server is available at port 3000:

import { MDNS } from 'mdns-local'

const mdns = new MDNS()

mdns.on('ready', () => {
  const service = mdns.advertise({
    name: 'My Web Server',
    type: '_http._tcp',
    port: 3000,
    txt: { path: '/api', version: '1.0' }
  })

  service.on('announced', () => {
    console.log('Service is live on the network')
  })

  service.on('conflict', (info) => {
    console.log('Name was taken — renamed to:', info.newName)
  })
})

Any mDNS browser on the network (Safari's Bonjour Browser, avahi-browse, another Node.js process) will now see your server.

Discover services on the network

Find every HTTP server that's advertising itself:

const browser = mdns.discover({ type: '_http._tcp' })

browser.on('up', (svc) => {
  console.log('Found:', svc.name, 'at', svc.host + ':' + svc.port)
  console.log('  IPs:', svc.addresses)
  console.log('  TXT:', svc.txt)
})

browser.on('down', (svc) => {
  console.log('Left:', svc.name)
})

Look up one specific service

You know the name — just get the details:

mdns.resolve('My Web Server', '_http._tcp', { timeout: 5000 }, (err, service) => {
  if (err) return console.error('Not found:', err.message)
  console.log('Reach it at:', service.host + ':' + service.port)
})

Reverse lookup (IP → hostname)

mdns.resolveByIP('192.168.1.50', (err, hostname) => {
  if (err) return console.error(err)
  console.log(hostname)  // 'macbook.local'
})

Just claim a hostname

Skip services — you just want myhost.local to resolve to your IP:

const host = mdns.claimHost('myrpi')
console.log(host.fqdn)   // 'myrpi.local'

host.on('announced', () => {
  console.log('You can now ping myrpi.local')
})

Shut down cleanly

Send goodbye packets so peers know you're leaving. Pass a callback to be sure they went out before your process exits:

mdns.destroy(() => {
  console.log('All goodbyes sent, socket closed')
  process.exit(0)
})

Concepts

A quick tour of what you'll see in the API.

Service type. A string like _http._tcp that identifies what kind of thing you offer. Two labels: a protocol (_http, _ssh, _printer) and a transport (_tcp or _udp). Convention, not free-form. Common types are listed below.

Service name. A human-readable label for your specific instance — My Web Server, David's Printer. Up to 63 characters. Two devices offering the same service type have different service names.

TXT record. Optional key/value data attached to your service. { path: '/api', version: '2.1' }. Useful for advertising capabilities the client should know before connecting.

Host / addresses. By default, mdns-local auto-detects your machine's hostname (e.g. laptop) and IP addresses, and uses laptop.local as the target of your service records. You can override with host and addresses if you need to.

Probing. Before announcing a service, mdns-local queries the network to check that the name isn't already taken (RFC 6762 §8.1). Takes about 750ms. During this window, your service isn't visible yet — service.probing is true, service.announced is false. Listen for the 'announced' event to know when it's live.

Conflict. If two devices announce the same name at the same moment, one wins (deterministically, by lexicographic comparison of their records — RFC 6762 §8.2) and the other renames to Name (2). You get a 'conflict' event when this happens.

Goodbye. When you stop a service or destroy the mdns instance, a "goodbye" packet is sent (TTL=0) so peers can remove you from their cache immediately instead of waiting for expiry.

Topology change. If your machine gets a new IP (Wi-Fi reconnect, Ethernet plug, VPN toggle), mdns-local detects the change and re-announces your services with the new address automatically. You can listen for the 'topology-change' event.

API Reference

new MDNS(options?)

Create an mDNS session. Opens UDP socket 5353 and joins the multicast group.

| Option | Default | Description | |--------|---------|-------------| | interface | all | IP address of the interface to use, or 'auto' | | ipv4 | true | Enable IPv4 multicast on 224.0.0.251 | | ipv6 | false | Enable IPv6 multicast on ff02::fb. Set with ipv4: true for dual-stack. | | ttl | 120 | Record TTL in seconds | | reuseAddr | true | Share port 5353 with other daemons (avahi, mDNSResponder) | | loopback | true | Receive our own outgoing packets | | multicastTTL | 255 | IP TTL for outgoing multicast packets | | domain | 'local' | mDNS domain | | reannouncementInterval | 60000 | Re-announce interval in ms | | topologyCheckInterval | 30000 | How often to poll for local IP changes (ms) | | strictSourceCheck | true | Drop packets from non-link-local sources (see Troubleshooting) |

Events

| Event | Args | Description | |-------|------|-------------| | ready | — | Socket bound and joined the multicast group | | error | MDNSNetworkError | Fatal socket error | | warning | string | Non-fatal issues (malformed packets, blocked sources, deferred announcements, etc.) | | topology-change | string[] | Local IP addresses changed; re-announcement was triggered | | destroyed | — | After destroy() completes |

Methods

mdns.advertise(opts)            // → ServiceHandle
mdns.claimHost(name, opts?)     // → HostHandle
mdns.addRecord(rec)             // → RecordHandle
mdns.discover(opts?)            // → BrowserHandle
mdns.resolve(name, type, [opts,] callback)
mdns.resolveByIP(ip, [opts,] callback)
mdns.resolveHostname(host, [opts,] callback)

mdns.getServices()              // → Service[]     — what I'm advertising
mdns.getDiscovered()            // → Service[]     — what I've found on the network
mdns.getHosts()                 // → Host[]        — hostnames I've claimed
mdns.getStats()                 // → Stats
mdns.destroy(callback?)         // send goodbyes, close socket, then invoke callback

Properties

| Property | Type | Description | |----------|------|-------------| | hostname | string | Auto-detected local hostname | | addresses | string[] | Auto-detected local IP addresses |

Event methods

mdns.on(event, fn)
mdns.once(event, fn)
mdns.off(event, fn)

mdns.advertise(options) → ServiceHandle

Announce a service on the network.

| Option | Required | Description | |--------|:--------:|-------------| | name | ✅ | Service instance name (max 63 chars, UTF-8) | | type | ✅ | Service type (_http._tcp, _ssh._tcp, etc.) | | port | ✅ | Port number (0–65535) | | txt | | TXT record as { key: value }. Values must be strings, numbers, or booleans — true sends the key with no value, as DNS-SD expects for flags. | | subtypes | | Subtypes as ['_printer'] | | host | | Target hostname (defaults to mdns.hostname) | | addresses | | IP addresses to advertise (defaults to mdns.addresses) | | priority | | SRV priority (default 0) | | weight | | SRV weight (default 0) |

Example

const service = mdns.advertise({
  name: 'David\'s Printer',
  type: '_printer._tcp',
  port: 631,
  txt: { color: 'yes', duplex: 'no' },
  subtypes: ['_color']
})

ServiceHandle

service.on('announced', () => {})       // multicast sent, service is live
service.on('conflict', (info) => {})    // name was taken — info.renamed, info.newName
service.on('goodbye', () => {})         // fired after service.stop() completes

service.update({ txt, port, addresses, subtypes })  // change and re-announce
service.stop()                          // send goodbye, remove from registry

service.name        // current name (changes if renamed by conflict)
service.type
service.port
service.announced   // boolean
service.probing     // boolean — true during the initial ~750ms probe

service.on(event, fn) / service.once(event, fn) / service.off(event, fn)

If you call service.update() while the service is still probing (or before the session is ready), the changes are stored but the announcement is deferred until probing completes. A warning is emitted so you know it happened.


mdns.claimHost(hostname, options?) → HostHandle

Register a hostname without a service. Useful when you just want myhost.local to resolve.

| Option | Description | |--------|-------------| | addresses | IP addresses (defaults to auto-detected) |

Example

const host = mdns.claimHost('myrpi', { addresses: ['192.168.1.5'] })

console.log(host.fqdn)  // 'myrpi.local'

host.on('announced', () => console.log('Now reachable as myrpi.local'))
host.on('conflict', (info) => console.log('Renamed to:', info.newName))

host.update({ addresses: ['192.168.1.6'] })
host.stop()

mdns.addRecord(record) → RecordHandle

Advanced — add an arbitrary DNS record. Useful for records mdns-local doesn't model directly (e.g. HINFO, custom types).

import { wire } from 'mdns-local'

const handle = mdns.addRecord({
  name: 'special.local',
  type: wire.DNS_TYPE.TXT,
  data: { foo: 'bar' },
  ttl: 120,
  flush: true
})

handle.remove()  // sends TTL=0 goodbye and removes

mdns.discover(options?) → BrowserHandle

Watch for services matching a filter.

| Option | Default | Description | |--------|---------|-------------| | type | none | Service type (_http._tcp). Omit to discover every type. | | types | none | Array form: ['_http._tcp', '_https._tcp']. Overrides type. | | subtype | none | Filter by subtype (_printer) | | filter | none | Callback (service) => boolean to narrow further | | domain | 'local' | mDNS domain | | existing | true | Replay peers already in cache when the browser is created |

Example

const browser = mdns.discover({ type: '_http._tcp' })

browser.on('up', (svc)     => console.log('found:',   svc.name))
browser.on('update', (svc) => console.log('updated:', svc.name))
browser.on('down', (svc)   => console.log('gone:',    svc.name))

browser.stop()

Why existing: true matters. The mdns instance is always listening — services can accumulate in its cache long before you create a browser. Without replay, the browser would report an empty network. Set existing: false if you only want notifications about services that appear after the browser is created.

BrowserHandle

browser.on/once/off(event, fn)
browser.stop()
browser.type       // the filter (or null)
browser.subtype

mdns.resolve(name, type, [opts,] callback)

One-shot resolution — wait for a specific service to appear, then fire the callback.

| Option | Default | Description | |--------|---------|-------------| | timeout | 5000 | ms before failing with MDNSTimeoutError |

Example

mdns.resolve('My Web Server', '_http._tcp', { timeout: 3000 }, (err, service) => {
  if (err) {
    console.error('Timeout or error:', err.message)
    return
  }
  console.log('Found:', service.host + ':' + service.port)
})

// Short form — default 5s timeout
mdns.resolve('My Web Server', '_http._tcp', (err, service) => { ... })

Callback is invoked exactly once. The callback signature is (err, service) in Node.js style.

Cache hit (peer already discovered) is delivered asynchronously via setImmediate — the callback never fires synchronously.


mdns.resolveByIP(ip, [opts,] callback)

Reverse resolution — find the hostname for an IP.

mdns.resolveByIP('192.168.1.50', { timeout: 2000 }, (err, hostname) => {
  if (err) return console.error(err)
  console.log(hostname)  // 'macbook.local'
})

mdns.resolveHostname(hostname, [opts,] callback)

Resolve an arbitrary .local hostname to IP address(es). Unlike resolve() which is service-oriented (SRV lookup), this is host-oriented (A/AAAA lookup).

| Option | Default | Description | |--------|---------|-------------| | timeout | 5000 | ms before failing | | ipv6 | false | Query for AAAA instead of A |

// The .local suffix is added if missing
mdns.resolveHostname('raspberrypi', (err, addresses) => {
  if (err) return console.error(err)
  console.log(addresses)  // ['192.168.1.50']
})

// IPv6
mdns.resolveHostname('server', { ipv6: true }, (err, addresses) => {
  console.log(addresses)  // ['fe80::1']
})

Callback receives an array of addresses (a host may have multiple).


mdns.getServices(), mdns.getDiscovered(), mdns.getHosts()

Snapshots of internal state.

mdns.getServices()      // what I'm advertising
mdns.getDiscovered()    // what I've found on the network
mdns.getHosts()         // hostnames I've claimed

Each returns an array of plain objects — safe to iterate or JSON-serialize.


mdns.destroy(callback?)

Send goodbye packets for every advertised service, custom record, and claimed host, then close the socket. The callback (if provided) fires after the goodbyes have been flushed to the OS and the socket is closed.

mdns.destroy(() => {
  console.log('All goodbyes sent, socket closed')
  process.exit(0)
})

If called without a callback, cleanup still happens; you just don't get a signal for when it finished. Do not call process.exit() immediately after — the goodbye packets may not have gone out yet.

Calling destroy() multiple times is safe. Every callback passed to any invocation fires exactly once.


Service object

The shape of objects returned by browser.on('up', ...), mdns.resolve(), mdns.getServices(), and mdns.getDiscovered():

{
  name: 'My Web Server',
  type: '_http._tcp',
  port: 3000,
  host: 'macbook.local',
  addresses: ['192.168.1.5'],
  txt: { path: '/api', version: '1.0' },
  subtypes: ['_printer'],
  interface: '192.168.1.5',      // interface we saw it on (discovered peers only)
  expiresAt: 1737000000000       // ms timestamp when TTL runs out (discovered peers only)
}

Statistics

const s = mdns.getStats()
// {
//   packetsSent, packetsReceived,
//   queriesReceived, responsesReceived,
//   announcementsSent, probesSent, goodbyesSent,
//   conflictsDetected, conflictsRenamed,
//   malformedPackets, ignoredPackets
// }

Useful for diagnostics and monitoring.

Common Recipes

Discovery

Discover everything on the network

const browser = mdns.discover()  // no type filter — all services

browser.on('up', (svc) => {
  console.log(`[${svc.type}] ${svc.name} @ ${svc.host}:${svc.port}`)
})

Discover multiple service types at once

const browser = mdns.discover({ types: ['_http._tcp', '_https._tcp'] })
browser.on('up', (svc) => console.log(`${svc.type}: ${svc.name}`))

Filter discovered services with a callback

// Only see services whose TXT record says they're the primary
const browser = mdns.discover({
  type: '_http._tcp',
  filter: (svc) => svc.txt.role === 'primary'
})

Discover the first HTTPS server, then stop looking

const browser = mdns.discover({ type: '_https._tcp' })
browser.once('up', (svc) => {
  console.log('First HTTPS server:', svc.name)
  browser.stop()
})

Wait for a specific service, then reach it

mdns.resolve('printer-office', '_ipp._tcp', (err, svc) => {
  if (err) return console.error('Printer offline')
  fetch(`http://${svc.host}:${svc.port}/status`)
})

Look up a hostname (not a service)

mdns.resolveHostname('printer', (err, addrs) => {
  if (err) return console.error('printer.local not found')
  console.log('printer.local is at', addrs.join(', '))
})

Advertising

Advertise multiple services from one process

mdns.advertise({ name: 'API',   type: '_http._tcp',  port: 3000 })
mdns.advertise({ name: 'Admin', type: '_https._tcp', port: 3443 })
mdns.advertise({ name: 'SSH',   type: '_ssh._tcp',   port: 22   })

Update TXT record dynamically

const svc = mdns.advertise({
  name: 'API', type: '_http._tcp', port: 3000,
  txt: { status: 'starting' }
})

svc.on('announced', () => {
  // ...boot your app...
  svc.update({ txt: { status: 'ready' } })
})

Advertise on both IPv4 and IPv6

const mdns = new MDNS({ ipv4: true, ipv6: true })
mdns.advertise({ name: 'Dual', type: '_http._tcp', port: 3000 })
// A and AAAA records both go out; peers see us on whichever family they prefer.

IPv6-only

const mdns = new MDNS({ ipv4: false, ipv6: true })

Lifecycle

Re-announce after topology changes (automatic)

Just listen for the event — the re-announcement happens for you:

mdns.on('topology-change', (addresses) => {
  console.log('IP changed, now:', addresses)
  // Services with auto-detected addresses were already re-announced.
})

Graceful shutdown on Ctrl-C

process.on('SIGINT', () => {
  console.log('Shutting down...')
  mdns.destroy(() => process.exit(0))
})

Service Types

| Type | Usage | |------|-------| | _http._tcp | HTTP server | | _https._tcp | HTTPS server | | _ssh._tcp | SSH server | | _ftp._tcp | FTP server | | _printer._tcp | Printer (often with _printer subtype of _http._tcp) | | _ipp._tcp | Internet Printing Protocol | | _airplay._tcp | Apple AirPlay | | _raop._tcp | AirPlay Audio | | _smb._tcp | Samba file sharing | | _afpovertcp._tcp | Apple Filing Protocol | | _mqtt._tcp | MQTT broker | | _coap._udp | CoAP (IoT) | | _homekit._tcp | Apple HomeKit | | _googlecast._tcp | Chromecast | | _spotify-connect._tcp | Spotify Connect |

Full registry: IANA Service Name and Transport Protocol Port Number Registry.

Error Handling

import {
  MDNSError,
  MDNSValidationError,
  MDNSConflictError,
  MDNSNetworkError,
  MDNSTimeoutError,
  MDNSStateError
} from 'mdns-local'

| Class | When | |-------|------| | MDNSError | Base class | | MDNSValidationError | Bad input — invalid service type, missing callback, etc. Has .field. | | MDNSConflictError | Name conflict (only when explicitly configured to throw) | | MDNSNetworkError | Socket error, bind failure, multicast join failure. Has .cause. | | MDNSTimeoutError | Operation timed out. Has .timeout (ms). | | MDNSStateError | Operation attempted on a destroyed session. Has .state. |

Validation errors are thrown synchronously. They mean you passed bad input:

try {
  mdns.advertise({ name: 'X', type: 'not-a-valid-type' })
} catch (err) {
  if (err instanceof MDNSValidationError) {
    console.log('Bad', err.field, ':', err.message)
  }
}

Timeout errors arrive via callback:

mdns.resolve('NoOne', '_http._tcp', { timeout: 1000 }, (err) => {
  if (err instanceof MDNSTimeoutError) {
    console.log('Nothing found in', err.timeout, 'ms')
  }
})

Network errors arrive via events:

mdns.on('error', (err) => {
  if (err instanceof MDNSNetworkError) {
    console.log('Socket problem:', err.message, err.cause)
  }
})

Warnings are informational — non-fatal issues like malformed packets, packets from blocked sources, or deferred updates:

mdns.on('warning', (msg) => console.warn(msg))

Debug Logging

Enable verbose logging to stderr:

MDNS_DEBUG=1 node app.js

Logs cover probing, announcing, conflicts, suppressions, and topology changes.

Troubleshooting

I see the "Dropped packet from ..." warning

By default, mdns-local drops packets that aren't from link-local or private IP addresses (RFC 6762 §11 — the source-address check). This is a security measure. If you're running in Docker, a VM, or behind a VPN, legitimate mDNS traffic may come from non-standard subnets and get dropped.

Disable the check if you trust your network:

const mdns = new MDNS({ strictSourceCheck: false })

announced never fires

  • Are you listening for it before it might have fired? Register the listener immediately after mdns.advertise(...). If you .on('announced', ...) too late, you missed it — use .once('announced', ...) if unsure, and check service.announced first.
  • Check service.probing. Probing takes ~750ms. If it's still true after several seconds, something is blocking outgoing multicast packets (firewall? Docker network mode?).

Nothing shows up in discover()

  • If you created the browser after peers announced, remember discover() replays existing peers by default. If it's still empty, the peers aren't reaching you — check firewall / VPN.
  • Try strictSourceCheck: false if you're in Docker / VM.
  • Enable MDNS_DEBUG=1 and look for Ignored non-link-local source.

Conflict on Linux: port 5353 already in use

Avahi is already running. mdns-local uses reuseAddr: true by default, which usually lets you share the port. If it still fails:

sudo systemctl stop avahi-daemon

Same idea on macOS with mDNSResponder — though there you generally can't stop it, so use reuseAddr: true (the default).

Node.js process hangs when I try to exit

mdns-local keeps the socket open. Call mdns.destroy(cb) before exiting:

mdns.destroy(() => process.exit(0))

Or if you need the process to exit even if a peer is misbehaving, destroy() has a 5-second safety timeout — it will force-close after that.

RFC Compliance

| RFC 6762 Requirement | Status | |---------------------|:------:| | Multicast 224.0.0.251:5353 | ✅ | | Continuous queries with exponential backoff (§5.2) | ✅ | | TTL refresh at 80% (§5.2) | ✅ | | Multiple questions per query (§5.3) | ✅ | | Unicast-response requests (§5.4) | ✅ | | NSEC negative responses (§6.1) | ✅ | | Wildcard ANY queries (§6.5) | ✅ | | Legacy unicast queries — include question, TTL cap, unicast reply (§6.7) | ✅ | | Known-Answer Suppression — incoming (§7.1) | ✅ | | Known-Answer Suppression — outgoing (§7.1) | ✅ | | Duplicate suppression (§7.3, §7.4) | ✅ | | Probing — 3 × 250ms (§8.1) | ✅ | | Probe tiebreaking — full multi-record comparison (§8.2) | ✅ | | Announcing — exponential (§8.3) | ✅ | | Service updating — re-probe on record data change (§8.4) | ✅ | | Conflict rename with 63-char label safety (§9) | ✅ | | Conflict rate limiting — 15 per 10s → 5s backoff (§9) | ✅ | | Goodbye TTL=0 (§10.1) | ✅ | | Cache flush bit (§10.2) | ✅ | | Topology change detection (§10.3) | ✅ | | Source address check (§11) | ✅ | | UTF-8 (§16) | ✅ | | Multipacket TC bit — split known-answer lists (§7.2, §17) | ✅ | | Sleep/Wake proxy (§10.4) | ⏳ not planned — see note below |

About Sleep/Wake proxy (§10.4)

RFC 6762 §10.4 describes a mechanism where a "Sleep Proxy Server" registers a device's records on the network before the device sleeps, then answers queries on the device's behalf until it wakes. This lets battery-powered devices (laptops, phones, IoT) appear reachable to peers without staying awake.

Why mdns-local doesn't implement this: Node.js processes don't sleep in the RFC's sense — a Node event loop that's paused isn't advertising anyway. There are two roles:

  • Being the sleeper — irrelevant. If your Node process is going to sleep, just call mdns.destroy() before it does.
  • Being the proxy — a specialized service (Apple's mDNSResponder implements this for AirPort base stations). Very rare use case; supporting it correctly would require ~1000 lines and OS-specific integration for very few users.

If you specifically need this, avahi-daemon on Linux and Apple's mDNSResponder on macOS both support it. mdns-local is aimed at Node servers that stay running.

About interface-per-service (multi-homed hosts)

Sometimes you want a service to be visible only on one network interface — e.g. a management API on 192.168.1.5 (LAN) but not on 10.0.0.5 (VPN).

Why there's no advertise({ interface: '...' }): Node's dgram module doesn't let you route a multicast send to a specific interface per-message. setMulticastInterface() is per-socket. Filtering at the application layer wouldn't actually contain the packet — it would still go out on other interfaces via the kernel's multicast routing.

The correct pattern is multiple MDNS instances, one per interface:

const lan = new MDNS({ interface: '192.168.1.5' })
const vpn = new MDNS({ interface: '10.0.0.5' })

lan.advertise({ name: 'Admin', type: '_http._tcp', port: 3000 })
// Only advertised on LAN; VPN peers won't see it.

vpn.advertise({ name: 'Public', type: '_http._tcp', port: 80 })

Each instance has its own socket bound to that interface's multicast group. The isolation is real: kernel-level, not just application-level filtering.

Robustness

The wire decoder parses bytes that arrive from anyone on the local network, so it is written to survive hostile input rather than trust it.

  • Bounds-checked reads. Every read validates against the buffer length and throws a catchable Error rather than reading undefined and letting NaN propagate through offset arithmetic.
  • Header sanity check. A packet's declared record counts are checked against the bytes actually present before anything is allocated. A 12-byte datagram claiming 65,535 records in each section is rejected immediately instead of driving a long allocation loop.
  • Name parsing limits. Compression pointers must point inside the packet, repeated targets are detected as loops, the pointer chain is capped, and names are held to the 255-byte DNS limit.
  • No prototype pollution. Every map keyed by network-supplied strings — discovered peers, TXT records, per-source query buffers — uses a null-prototype object, so a peer advertising a key named __proto__ or constructor is stored as ordinary data.
  • Bounded per-source state. Buffered multipacket queries are capped; past the cap, queries are answered from the packet at hand rather than accumulating state.

These are covered by test-fuzz.mjs, which throws roughly 40,000 random and hand-built hostile packets at the decoder and fails the build on any hang, crash, or unbounded allocation.

Comparison

Feature-by-feature against other npm packages. The columns are all the currently-listed alternatives; every row is either implemented (✅), partially implemented, or missing (❌).

| Feature | mdns-local | mdns | bonjour | bonjour-hap | |---------|:--------------:|:-----:|:---------:|:-------------:| | Pure JavaScript (no native bindings) | ✅ | ❌ native | ✅ | ✅ | | Advertise | ✅ | ✅ | ✅ | ✅ | | Discover | ✅ | ✅ | ✅ | ✅ | | Probing (RFC §8.1) | ✅ | ✅ | ❌ | ❌ | | Probe tiebreaking — full (RFC §8.2) | ✅ | ✅ | ❌ | ❌ | | Conflict rename with length cap | ✅ | ✅ | ❌ | ❌ | | Conflict rate limiting (RFC §9) | ✅ | ✅ | ❌ | ❌ | | Known-Answer Suppression (in + out) | ✅ | ✅ | ❌ | ❌ | | TTL refresh at 80% | ✅ | ✅ | ❌ | ❌ | | NSEC negative responses | ✅ | ✅ | ❌ | ❌ | | Legacy unicast responses (RFC §6.7) | ✅ | ✅ | ❌ | ❌ | | Source address check | ✅ | ✅ | ❌ | ❌ | | Subtypes | ✅ | ✅ | partial | ❌ | | Multiple types per browser | ✅ | ❌ | ❌ | ❌ | | Filter callback in discover | ✅ | ❌ | ❌ | ❌ | | Custom records | ✅ | ✅ | ❌ | ❌ | | Hostname-only registration | ✅ | ✅ | ❌ | ❌ | | Resolve arbitrary .local hostname | ✅ | ❌ | ❌ | ❌ | | Reverse PTR (IP → hostname) | ✅ | ✅ | ❌ | ❌ | | Re-probe on record data change | ✅ | ✅ | ❌ | ❌ | | Topology change detection | ✅ | ✅ | ❌ | ❌ | | Dual-stack IPv4 + IPv6 simultaneously | ✅ | ✅ | ❌ | ❌ | | Address validation on input | ✅ | ❌ | ❌ | ❌ | | Fuzz-hardened wire decoder | ✅ | ❌ | ❌ | ❌ | | Statistics | ✅ | ❌ | ❌ | ❌ | | Debug logging | ✅ | ❌ | partial | ❌ | | Maintained | ✅ | ❌ | ❌ 10y | ❌ "do not use" |

A note on the other packages:

  • mdns — the closest in behavior, but requires the Bonjour SDK and native compilation. Blocked on most cloud builds and CI containers.
  • bonjour — hasn't seen a release in a decade. Missing probing, conflict handling, and known-answer suppression.
  • bonjour-hap — a fork of bonjour used inside hap-nodejs. The README explicitly warns "do not use" outside HAP because it floods the network.
  • node-dns-sd — discovery only. No advertising path.
  • mdns-js — never reached a stable RFC-compliant state; last meaningful commit is years old.

Architecture

src/
  wire.js             — DNS protocol: constants, binary helpers, encode/decode
  mdns_session.js     — protocol engine: probe/announce/discover state
  mdns.js             — transport: UDP multicast socket + auto-detect
  errors.js           — error classes
  debug.js            — debug logging helper
  index.js            — entry point

Three layers:

  • wire.js — pure functions. DNS packet format, constants, encoding, decoding. No state, no I/O.
  • mdns_session.js — protocol engine. Reactive: receives buffers, emits buffers. Manages advertised services, hostnames, discovered peers, browsers. No socket — testable in-process without a network.
  • mdns.js — transport binding. Creates the UDP socket(s), connects them to the session, handles auto-detection of interfaces and topology changes.

The session layer is transport-agnostic: any code that can hand it inbound bytes and route its outbound bytes can drive it. That's why 100+ tests run without touching a real network — the session is spoken to directly with encoded packets and its outbound events are captured.

Community contributions are welcome! Please ⭐ star the repo to follow progress.

📜 License

Apache License 2.0

Copyright © 2026 colocohen

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.