dns-sd-browser 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +670 -0
- package/dist/browser.d.ts +145 -0
- package/dist/constants.d.ts +12 -0
- package/dist/dns.d.ts +155 -0
- package/dist/index.d.ts +113 -0
- package/dist/service.d.ts +115 -0
- package/dist/transport.d.ts +67 -0
- package/lib/browser.js +1661 -0
- package/lib/constants.js +17 -0
- package/lib/dns.js +685 -0
- package/lib/index.js +252 -0
- package/lib/service.js +152 -0
- package/lib/transport.js +345 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
# dns-sd-browser
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dns-sd-browser)
|
|
4
|
+
[](https://github.com/gmaclennan/dns-sd-browser/actions/workflows/test.yml)
|
|
5
|
+
[](https://codecov.io/gh/gmaclennan/dns-sd-browser)
|
|
6
|
+
|
|
7
|
+
Spec-compliant [DNS-SD](https://www.rfc-editor.org/rfc/rfc6763) browser over [Multicast DNS](https://www.rfc-editor.org/rfc/rfc6762) for Node.js.
|
|
8
|
+
|
|
9
|
+
- **Async iterator API** — modern, backpressure-aware, no forgotten error handlers
|
|
10
|
+
- **Zero dependencies** — pure JavaScript, no native bindings
|
|
11
|
+
- **RFC compliant** — continuous querying, known-answer suppression, TTL expiration, IPv4+IPv6 multicast
|
|
12
|
+
- **Interoperable** — lenient with real-world advertiser quirks, strict on security
|
|
13
|
+
- **JSDoc typed** — full type information via JSDoc, checkable with TypeScript
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
npm install dns-sd-browser
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Node.js >= 22.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { DnsSdBrowser } from 'dns-sd-browser'
|
|
27
|
+
|
|
28
|
+
const mdns = new DnsSdBrowser()
|
|
29
|
+
const browser = mdns.browse('_http._tcp')
|
|
30
|
+
|
|
31
|
+
for await (const event of browser) {
|
|
32
|
+
switch (event.type) {
|
|
33
|
+
case 'serviceUp':
|
|
34
|
+
console.log(`Found: ${event.service.name} at ${event.service.host}:${event.service.port}`)
|
|
35
|
+
console.log(` Addresses: ${event.service.addresses.join(', ')}`)
|
|
36
|
+
console.log(` TXT:`, event.service.txt)
|
|
37
|
+
break
|
|
38
|
+
case 'serviceDown':
|
|
39
|
+
console.log(`Lost: ${event.service.name}`)
|
|
40
|
+
break
|
|
41
|
+
case 'serviceUpdated':
|
|
42
|
+
console.log(`Updated: ${event.service.name}`, event.service.txt)
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Find the first service
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
const browser = mdns.browse('_http._tcp', {
|
|
52
|
+
signal: AbortSignal.timeout(10_000)
|
|
53
|
+
})
|
|
54
|
+
for await (const event of browser) {
|
|
55
|
+
if (event.type === 'serviceUp') {
|
|
56
|
+
console.log(event.service.name, event.service.host, event.service.port)
|
|
57
|
+
break // stops the browser
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Object-form service type
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
// These are equivalent:
|
|
66
|
+
mdns.browse('_http._tcp')
|
|
67
|
+
mdns.browse({ name: 'http', protocol: 'tcp' })
|
|
68
|
+
mdns.browse({ name: 'http' }) // protocol defaults to 'tcp'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Browse a service subtype
|
|
72
|
+
|
|
73
|
+
Discover services registered under a specific subtype (RFC 6763 §7.1):
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const browser = mdns.browse('_http._tcp', { subtype: '_printer' })
|
|
77
|
+
|
|
78
|
+
for await (const event of browser) {
|
|
79
|
+
if (event.type === 'serviceUp') {
|
|
80
|
+
console.log(`Printer: ${event.service.name}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Browse all services
|
|
86
|
+
|
|
87
|
+
Discover every service on the network, regardless of type. Automatically enumerates service types and browses each one for fully resolved instances:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
const browser = mdns.browseAll()
|
|
91
|
+
|
|
92
|
+
for await (const event of browser) {
|
|
93
|
+
if (event.type === 'serviceUp') {
|
|
94
|
+
console.log(`[${event.service.type}] ${event.service.name} at ${event.service.host}:${event.service.port}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Browse service types only
|
|
100
|
+
|
|
101
|
+
If you only need to know which service types exist (without resolving instances), use `browseTypes()`:
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
const browser = mdns.browseTypes()
|
|
105
|
+
|
|
106
|
+
for await (const event of browser) {
|
|
107
|
+
if (event.type === 'serviceUp') {
|
|
108
|
+
console.log('Service type found:', event.service.fqdn)
|
|
109
|
+
// event.service.host/port/addresses will be empty — these are types, not instances
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Stopping a browser
|
|
115
|
+
|
|
116
|
+
Breaking out of a `for await` loop or aborting via `AbortSignal` automatically stop the browser — no manual cleanup needed:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
// break / return automatically stops the browser
|
|
120
|
+
for await (const event of browser) {
|
|
121
|
+
if (event.type === 'serviceUp') {
|
|
122
|
+
break // browser is stopped and cleaned up
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// AbortSignal throws AbortError when aborted
|
|
127
|
+
const browser = mdns.browse('_http._tcp', {
|
|
128
|
+
signal: AbortSignal.timeout(10_000)
|
|
129
|
+
})
|
|
130
|
+
try {
|
|
131
|
+
for await (const event of browser) {
|
|
132
|
+
console.log(event)
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err.name === 'TimeoutError') {
|
|
136
|
+
console.log('Browsing timed out')
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Call `browser.destroy()` explicitly only if you are **not** consuming the async iterator (e.g. only polling `browser.services`):
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const browser = mdns.browse('_http._tcp')
|
|
145
|
+
|
|
146
|
+
// Poll the live services map without iterating
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
console.log('Found:', [...browser.services.values()])
|
|
149
|
+
browser.destroy() // must destroy manually since we never iterated
|
|
150
|
+
}, 5000)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Current services snapshot
|
|
154
|
+
|
|
155
|
+
`browser.services` is a live `Map<string, Service>` that reflects all currently discovered services, updated regardless of whether you're actively iterating:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
const browser = mdns.browse('_http._tcp')
|
|
159
|
+
|
|
160
|
+
// Later, check what's been found:
|
|
161
|
+
for (const [fqdn, service] of browser.services) {
|
|
162
|
+
console.log(fqdn, service.host, service.port)
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Removing unreachable services
|
|
167
|
+
|
|
168
|
+
If your application detects that a service is unreachable (e.g. via a health check), you can remove it from the browser without waiting for its TTL to expire:
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
browser.removeService('My Printer._http._tcp.local')
|
|
172
|
+
// Emits serviceDown and clears the cached record.
|
|
173
|
+
// If the advertiser re-announces, it will appear as a fresh serviceUp.
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This is useful on unreliable networks where devices disappear without sending goodbye packets. Most mDNS advertisers (including Android's NSD) use a 75-minute TTL, so without manual removal, stale services would linger for a long time.
|
|
177
|
+
|
|
178
|
+
### Reconfirming suspect services
|
|
179
|
+
|
|
180
|
+
If your application suspects a service may be stale but isn't certain (e.g. a connection timed out, but it could be a transient network issue), use `reconfirm()` instead of `removeService()`. This implements the RFC 6762 §10.4 cache flush on failure indication: the browser sends verification queries and only removes the service if the advertiser fails to respond within 10 seconds.
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
browser.reconfirm('My Printer._http._tcp.local')
|
|
184
|
+
// Sends 2 verification queries over ~2 seconds.
|
|
185
|
+
// If the advertiser responds, the service stays (it's still alive).
|
|
186
|
+
// If no response within 10 seconds, emits serviceDown and removes it.
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Use `reconfirm()` when a connection fails but you want to give the advertiser a chance to prove it's still there — for example, after a TCP connection is refused or a health check times out. Use `removeService()` when you're certain the service is gone and want it removed immediately.
|
|
190
|
+
|
|
191
|
+
### Passive Observation of Failures (POOF)
|
|
192
|
+
|
|
193
|
+
The browser automatically detects stale services using POOF (RFC 6762 §10.5). When other devices on the network query for a service type that the browser has cached, the browser watches for responses. If two or more queries go unanswered within a 10-second window, the unresponsive service is automatically flushed — no application code required.
|
|
194
|
+
|
|
195
|
+
This works because on a healthy network, queries from any device should prompt the advertiser to respond. If no response is observed, the advertiser has likely left the network without sending a goodbye packet. POOF only counts multicast (QM) queries, not unicast-response (QU) queries, since unicast responses are sent directly to the querier and can't be observed by other hosts.
|
|
196
|
+
|
|
197
|
+
POOF is most effective on networks with multiple mDNS clients — each client's queries give other clients a chance to observe whether advertisers are still responding. On a network where your application is the only mDNS client, POOF has no effect since there are no queries from other hosts to observe, and the library falls back to TTL expiration and refresh queries.
|
|
198
|
+
|
|
199
|
+
### Handling unstable networks
|
|
200
|
+
|
|
201
|
+
On unreliable networks — devices walking out of WiFi range, mobile devices sleeping, IoT devices rebooting — services often disappear without sending a goodbye packet. The library provides several layers of defense against stale services, some fully automatic and some requiring application involvement:
|
|
202
|
+
|
|
203
|
+
**Handled automatically by the library (no application code needed):**
|
|
204
|
+
|
|
205
|
+
| Mechanism | How it works | Typical delay |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| **Goodbye packets** (RFC 6762 §10.1) | Advertiser sends TTL=0 record before leaving | Immediate (1-second grace period to absorb flicker) |
|
|
208
|
+
| **TTL expiration with refresh** (RFC 6762 §5.2) | Queries at 80%, 85%, 90%, 95% of TTL; removes if no response by 100% | Depends on TTL (often 75 min for Android NSD, 2 min for Avahi) |
|
|
209
|
+
| **POOF** (RFC 6762 §10.5) | Flushes records after 2+ unanswered queries from other network hosts | ~10 seconds, but requires other mDNS clients on the network |
|
|
210
|
+
| **Cache-flush bit** (RFC 6762 §10.2) | When an advertiser re-announces with cache-flush set, stale addresses from other advertisers are flushed | Immediate (1-second grace period for multi-packet bursts) |
|
|
211
|
+
|
|
212
|
+
**Requires application involvement:**
|
|
213
|
+
|
|
214
|
+
| Mechanism | When to use |
|
|
215
|
+
|---|---|
|
|
216
|
+
| **`reconfirm(fqdn)`** | A connection failed but the service might recover (e.g. TCP timeout). Sends verification queries and removes if no response within 10 seconds. |
|
|
217
|
+
| **`removeService(fqdn)`** | Your health check confirms the service is definitely gone. Removes immediately. |
|
|
218
|
+
| **`mdns.rejoin()`** | The network interface changed (WiFi reconnect, Ethernet re-plug). Flushes all services and restarts discovery. |
|
|
219
|
+
|
|
220
|
+
**When do you need application-level monitoring?** The automatic mechanisms handle the common cases, but they have limitations on unstable networks:
|
|
221
|
+
|
|
222
|
+
- **TTL expiration** is the primary automatic fallback when a device disappears silently, but standard TTLs are long — 75 minutes for Android NSD, 120 seconds for Avahi. Your application may show stale services for that entire duration.
|
|
223
|
+
- **POOF** can detect stale services much faster (~10 seconds), but only works when other mDNS clients on the network happen to query for the same service type.
|
|
224
|
+
- **Neither mechanism provides real-time detection.** If your application needs to know immediately when a service is unreachable (e.g. to update a UI or fail over to another service), implement application-level health checks and call `reconfirm()` or `removeService()` based on the results.
|
|
225
|
+
|
|
226
|
+
A practical pattern for unstable networks:
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
for await (const event of browser) {
|
|
230
|
+
if (event.type === 'serviceUp' || event.type === 'serviceUpdated') {
|
|
231
|
+
startHealthCheck(event.service)
|
|
232
|
+
}
|
|
233
|
+
if (event.type === 'serviceDown') {
|
|
234
|
+
stopHealthCheck(event.service)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function startHealthCheck(service) {
|
|
239
|
+
// Periodically verify the service is reachable
|
|
240
|
+
const interval = setInterval(async () => {
|
|
241
|
+
const reachable = await ping(service)
|
|
242
|
+
if (!reachable) {
|
|
243
|
+
clearInterval(interval)
|
|
244
|
+
// Give the advertiser a chance to respond before removing
|
|
245
|
+
browser.reconfirm(service.fqdn)
|
|
246
|
+
}
|
|
247
|
+
}, 30_000)
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Cleanup
|
|
252
|
+
|
|
253
|
+
Always destroy the `DnsSdBrowser` instance when done to close the mDNS socket. Destroying the `DnsSdBrowser` also stops all its browsers:
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
await mdns.destroy() // stops all browsers and closes the socket
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Or use `await using` for automatic cleanup:
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
{
|
|
263
|
+
await using mdns = new DnsSdBrowser()
|
|
264
|
+
const browser = mdns.browse('_http._tcp')
|
|
265
|
+
// mdns and all browsers cleaned up at end of block
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## API
|
|
270
|
+
|
|
271
|
+
### `new DnsSdBrowser(options?)`
|
|
272
|
+
|
|
273
|
+
Create a new DNS-SD browser instance. Manages a shared mDNS socket.
|
|
274
|
+
|
|
275
|
+
| Option | Type | Default | Description |
|
|
276
|
+
|--------|------|---------|-------------|
|
|
277
|
+
| `port` | `number` | `5353` | mDNS UDP port |
|
|
278
|
+
| `interface` | `string` | `'0.0.0.0'` | Network interface IP to bind to |
|
|
279
|
+
|
|
280
|
+
### `mdns.browse(serviceType, options?)`
|
|
281
|
+
|
|
282
|
+
Start browsing for a service type. Returns a `ServiceBrowser`.
|
|
283
|
+
|
|
284
|
+
- **serviceType**: `string` like `'_http._tcp'` or object `{ name: 'http', protocol?: 'tcp' }`
|
|
285
|
+
- **options.signal**: `AbortSignal` to cancel browsing
|
|
286
|
+
- **options.subtype**: `string` — browse a service subtype (RFC 6763 §7.1), e.g. `browse('_http._tcp', { subtype: '_printer' })` queries `_printer._sub._http._tcp.local`
|
|
287
|
+
|
|
288
|
+
### `mdns.browseAll(options?)`
|
|
289
|
+
|
|
290
|
+
Browse for all service instances on the network, regardless of type. Automatically enumerates service types and browses each for instances. Returns an `AllServiceBrowser` (same async iterable interface as `ServiceBrowser`).
|
|
291
|
+
|
|
292
|
+
- **options.signal**: `AbortSignal` to cancel browsing
|
|
293
|
+
|
|
294
|
+
### `mdns.browseTypes(options?)`
|
|
295
|
+
|
|
296
|
+
Browse for service types on the network. Returns lightweight `Service` objects representing types — `host`, `port`, and `addresses` will be empty.
|
|
297
|
+
|
|
298
|
+
- **options.signal**: `AbortSignal` to cancel browsing
|
|
299
|
+
|
|
300
|
+
### `mdns.ready()`
|
|
301
|
+
|
|
302
|
+
Returns a `Promise<void>` that resolves when the mDNS socket is bound and ready.
|
|
303
|
+
|
|
304
|
+
### `mdns.rejoin()`
|
|
305
|
+
|
|
306
|
+
Re-join multicast groups and restart all browsers after a network interface change (e.g. WiFi reconnect, Ethernet re-plug).
|
|
307
|
+
|
|
308
|
+
The OS drops multicast group membership when an interface goes down. This method re-establishes it, emits `serviceDown` for all previously discovered services, and restarts querying with the initial rapid schedule so services on the new network are discovered quickly.
|
|
309
|
+
|
|
310
|
+
Without calling `rejoin()`, previously discovered services would still eventually expire via their TTL timers (typically ~75 minutes), but the socket would not receive any new multicast responses until the multicast group is re-joined.
|
|
311
|
+
|
|
312
|
+
All previously known services are flushed as `serviceDown` because the browser cannot know whether you reconnected to the same network or a different one. On a different network those services don't exist; on the same network they will be re-discovered within seconds via the restarted query schedule.
|
|
313
|
+
|
|
314
|
+
Unlike `destroy()` followed by a new `browse()`, `rejoin()` is a lightweight operation that preserves the existing sockets and async iterators. The UDP socket binding and multicast group membership are refreshed in-place, and consumers of the async iterator continue receiving events without interruption — first `serviceDown` for all previously known services, then `serviceUp` as services are rediscovered. With `destroy()`, the sockets are closed, all iterators end, and you would need to create a new `DnsSdBrowser` instance and call `browse()` again with fresh iterator references.
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
// Call from your application's network change handler
|
|
318
|
+
mdns.rejoin()
|
|
319
|
+
|
|
320
|
+
// The async iterator will receive serviceDown for all previous services,
|
|
321
|
+
// followed by serviceUp as services are re-discovered on the new network
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### `mdns.destroy()`
|
|
325
|
+
|
|
326
|
+
Stop all browsers and close the mDNS socket. Returns `Promise<void>`.
|
|
327
|
+
|
|
328
|
+
### `ServiceBrowser`
|
|
329
|
+
|
|
330
|
+
Returned by `browse()` and `browseTypes()`. Implements `AsyncIterable<BrowseEvent>`.
|
|
331
|
+
|
|
332
|
+
### `AllServiceBrowser`
|
|
333
|
+
|
|
334
|
+
Returned by `browseAll()`. Same interface as `ServiceBrowser` — the `services` Map contains instances from all discovered types.
|
|
335
|
+
|
|
336
|
+
Both `ServiceBrowser` and `AllServiceBrowser` share this interface:
|
|
337
|
+
|
|
338
|
+
| Property/Method | Type | Description |
|
|
339
|
+
|-----------------|------|-------------|
|
|
340
|
+
| `services` | `Map<string, Service>` | Live map of currently discovered services |
|
|
341
|
+
| `removeService(fqdn)` | `boolean` | Manually remove a service, emitting `serviceDown`. Returns `true` if found. |
|
|
342
|
+
| `reconfirm(fqdn)` | `void` | Verify a service is still alive (RFC 6762 §10.4). Sends queries and removes the service if no response within 10 seconds. |
|
|
343
|
+
| `destroy()` | `void` | Stop browsing and end iteration (called automatically by `break` and `AbortSignal`) |
|
|
344
|
+
| `resetNetwork()` | `void` | Flush services and restart queries (called by `mdns.rejoin()`) |
|
|
345
|
+
| `[Symbol.asyncIterator]()` | `AsyncIterableIterator<BrowseEvent>` | Iterate over discovery events |
|
|
346
|
+
| `[Symbol.asyncDispose]()` | `Promise<void>` | For `await using` support |
|
|
347
|
+
|
|
348
|
+
### `BrowseEvent`
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
type BrowseEvent =
|
|
352
|
+
| { type: 'serviceUp', service: Service }
|
|
353
|
+
| { type: 'serviceDown', service: Service }
|
|
354
|
+
| { type: 'serviceUpdated', service: Service }
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
#### Service resolution lifecycle
|
|
358
|
+
|
|
359
|
+
Discovering a DNS-SD service requires multiple DNS record types, each carrying a different piece of information:
|
|
360
|
+
|
|
361
|
+
1. **PTR** record — maps a service type (`_http._tcp.local`) to a specific instance name (`My Printer._http._tcp.local`). This is what browsing queries for.
|
|
362
|
+
2. **SRV** record — provides the target hostname and port for that instance (`printer.local:631`).
|
|
363
|
+
3. **TXT** record — carries metadata as key-value pairs (`path=/api`, `version=2`).
|
|
364
|
+
4. **A / AAAA** records — resolve the hostname to IPv4/IPv6 addresses (`192.168.1.50`).
|
|
365
|
+
|
|
366
|
+
Advertisers typically send all of these in a single response packet with the SRV, TXT, and address records in the "additionals" section. However, records can arrive in separate packets under normal conditions — for example, when a host's address changes (DHCP renewal), the advertiser sends just the new A record without re-sending the PTR or SRV. Records can also be split when the response exceeds the 1472-byte mDNS packet limit, or when different records have independent TTLs and are refreshed at different times.
|
|
367
|
+
|
|
368
|
+
This library emits `serviceUp` as soon as the SRV record is resolved (providing `host` and `port`). Other records may arrive in later packets — the service is progressively filled in via `serviceUpdated` events:
|
|
369
|
+
|
|
370
|
+
| Event | When | What's guaranteed | What may be empty |
|
|
371
|
+
|-------|------|-------------------|-------------------|
|
|
372
|
+
| `serviceUp` | SRV record resolved | `name`, `host`, `port`, `fqdn` | `addresses`, `txt`, `subtypes` |
|
|
373
|
+
| `serviceUpdated` | Any field changed | All fields reflect current state | — |
|
|
374
|
+
| `serviceDown` | TTL expired or goodbye | Snapshot at time of removal | — |
|
|
375
|
+
|
|
376
|
+
Each service emits exactly one `serviceUp`, followed by zero or more `serviceUpdated`, and at most one `serviceDown`. You will never receive a second `serviceUp` for the same service.
|
|
377
|
+
|
|
378
|
+
This matters when A/AAAA records arrive in a separate packet from the SRV (a split response). The `serviceUp` will have `addresses: []`, and a `serviceUpdated` follows shortly after with the addresses populated:
|
|
379
|
+
|
|
380
|
+
```js
|
|
381
|
+
const resolved = new Map()
|
|
382
|
+
|
|
383
|
+
for await (const event of browser) {
|
|
384
|
+
if (event.type === 'serviceDown') {
|
|
385
|
+
resolved.delete(event.service.fqdn)
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
const svc = event.service
|
|
389
|
+
if (svc.addresses.length > 0 && !resolved.has(svc.fqdn)) {
|
|
390
|
+
resolved.set(svc.fqdn, svc)
|
|
391
|
+
console.log(`Ready: ${svc.name} at ${svc.addresses[0]}:${svc.port}`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
If you only need a service once it has addresses, you can also poll the `services` Map — it always reflects the latest state regardless of which events you've consumed.
|
|
397
|
+
|
|
398
|
+
### `Service`
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
interface Service {
|
|
402
|
+
name: string // Instance name ("My Printer")
|
|
403
|
+
type: string // Service type ("_http._tcp")
|
|
404
|
+
protocol: string // "tcp" or "udp"
|
|
405
|
+
domain: string // "local"
|
|
406
|
+
host: string // Target hostname ("printer.local")
|
|
407
|
+
port: number // Port number
|
|
408
|
+
addresses: string[] // IPv4 and IPv6 addresses
|
|
409
|
+
txt: Record<string, string | true> // Parsed TXT key-value pairs
|
|
410
|
+
txtRaw: Record<string, Uint8Array> // Raw TXT values
|
|
411
|
+
fqdn: string // Fully qualified name
|
|
412
|
+
subtypes: string[] // Service subtypes
|
|
413
|
+
updatedAt: number // Timestamp (ms)
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
## Edge cases and caveats
|
|
418
|
+
|
|
419
|
+
### `break` stops the browser
|
|
420
|
+
|
|
421
|
+
`break` or `return` from a `for await` loop automatically stops the browser — it cannot be iterated again. If you need to find the first service and then keep browsing, consume events without breaking:
|
|
422
|
+
|
|
423
|
+
```js
|
|
424
|
+
const browser = mdns.browse('_http._tcp')
|
|
425
|
+
let firstService
|
|
426
|
+
for await (const event of browser) {
|
|
427
|
+
if (event.type === 'serviceUp' && !firstService) {
|
|
428
|
+
firstService = event.service
|
|
429
|
+
// don't break — keep browsing for more services
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### AbortSignal throws, doesn't end cleanly
|
|
435
|
+
|
|
436
|
+
Aborting via `AbortSignal` throws the abort reason from the `for await` loop, matching the Node.js convention (`events.on`, `Readable`, `setInterval` all throw `AbortError`). Use try/catch to handle it:
|
|
437
|
+
|
|
438
|
+
```js
|
|
439
|
+
try {
|
|
440
|
+
for await (const event of browser) { /* ... */ }
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (err.name === 'AbortError') {
|
|
443
|
+
// browsing was cancelled
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
In contrast, `browser.destroy()` ends iteration cleanly (no throw).
|
|
449
|
+
|
|
450
|
+
### Single async iterator
|
|
451
|
+
|
|
452
|
+
Each `ServiceBrowser` supports only **one** active async iterator at a time. Attempting to create a second will throw:
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
const browser = mdns.browse('_http._tcp')
|
|
456
|
+
for await (const event of browser) { /* ... */ } // ok
|
|
457
|
+
for await (const event of browser) { /* ... */ } // throws — iterator already active
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
If you need multiple consumers, read from `browser.services` (the live Map) instead.
|
|
461
|
+
|
|
462
|
+
### `browseAll()` returns partial Service objects
|
|
463
|
+
|
|
464
|
+
`browseAll()` queries for service _types_, not service _instances_. The returned `Service` objects represent service types and have incomplete fields:
|
|
465
|
+
|
|
466
|
+
```js
|
|
467
|
+
const browser = mdns.browseAll()
|
|
468
|
+
for await (const event of browser) {
|
|
469
|
+
// event.service.fqdn → "_http._tcp.local" (the type, not an instance)
|
|
470
|
+
// event.service.host → ""
|
|
471
|
+
// event.service.port → 0
|
|
472
|
+
// event.service.addresses → []
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
To discover actual service instances, use the type from `browseAll()` to start a targeted `browse()`.
|
|
477
|
+
|
|
478
|
+
### `ready()` requires a prior `browse()` call
|
|
479
|
+
|
|
480
|
+
The mDNS transport is started lazily on the first `browse()` or `browseAll()` call. Calling `ready()` before any browse will throw:
|
|
481
|
+
|
|
482
|
+
```js
|
|
483
|
+
const mdns = new DnsSdBrowser()
|
|
484
|
+
await mdns.ready() // throws — transport not started yet
|
|
485
|
+
|
|
486
|
+
const browser = mdns.browse('_http._tcp')
|
|
487
|
+
await mdns.ready() // ok — transport is starting
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Transport start errors are deferred
|
|
491
|
+
|
|
492
|
+
If the mDNS socket fails to bind (e.g. permission denied, port conflict), the error is **not** thrown from `browse()`. The browser will silently produce no events. To surface transport errors, call `ready()` after starting a browse:
|
|
493
|
+
|
|
494
|
+
```js
|
|
495
|
+
const browser = mdns.browse('_http._tcp')
|
|
496
|
+
await mdns.ready() // throws if socket binding failed
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Event buffer overflow
|
|
500
|
+
|
|
501
|
+
Events are buffered (up to 4,096) while waiting for the async iterator to consume them. If a consumer is too slow, the **oldest events are silently dropped**. This means a slow consumer could miss `serviceUp` events and later receive `serviceDown` for services it never saw appear. The `browser.services` Map always reflects the current state regardless of buffer overflow.
|
|
502
|
+
|
|
503
|
+
### `services` Map keys are FQDNs
|
|
504
|
+
|
|
505
|
+
The `browser.services` Map is keyed by the fully qualified service name (e.g. `"My Printer._http._tcp.local"`), not the short instance name. Use `service.name` for the human-readable name:
|
|
506
|
+
|
|
507
|
+
```js
|
|
508
|
+
browser.services.get('My Printer._http._tcp.local') // ✓
|
|
509
|
+
browser.services.get('My Printer') // ✗ undefined
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## RFC Compliance
|
|
513
|
+
|
|
514
|
+
This library implements the browser/querier side of:
|
|
515
|
+
|
|
516
|
+
- **[RFC 6762](https://www.rfc-editor.org/rfc/rfc6762)** — Multicast DNS
|
|
517
|
+
- IPv4 and IPv6 multicast (224.0.0.251 and FF02::FB)
|
|
518
|
+
- Continuous querying with exponential backoff (1s, 2s, 4s... up to 1h)
|
|
519
|
+
- QU (unicast-response) bit on initial queries (§5.4)
|
|
520
|
+
- Known-answer suppression in queries (§7.1)
|
|
521
|
+
- TTL-based cache expiration — services are removed when their TTL expires
|
|
522
|
+
- TTL refresh queries at 80/85/90/95% of TTL (§5.2)
|
|
523
|
+
- Cache-flush bit handling (§10.2)
|
|
524
|
+
- Goodbye packets (TTL=0) with 1-second grace period (§10.1)
|
|
525
|
+
- Cache flush on failure — `reconfirm()` API (§10.4)
|
|
526
|
+
- Passive Observation of Failures (POOF) — automatic stale record flushing (§10.5)
|
|
527
|
+
- Duplicate question suppression (§7.3)
|
|
528
|
+
- Truncated response handling — re-queries with QU bit when TC is set (§18.5)
|
|
529
|
+
- DNS name compression (encoding and decoding)
|
|
530
|
+
- Malformed packet rejection with detailed errors
|
|
531
|
+
|
|
532
|
+
- **[RFC 6763](https://www.rfc-editor.org/rfc/rfc6763)** — DNS-Based Service Discovery
|
|
533
|
+
- PTR record browsing for service instances
|
|
534
|
+
- SRV record resolution (host, port)
|
|
535
|
+
- TXT record parsing (key=value, boolean flags, case-insensitive dedup)
|
|
536
|
+
- Service type enumeration (`_services._dns-sd._udp.local`)
|
|
537
|
+
- Subtype browsing (`_subtype._sub._type._proto.local`)
|
|
538
|
+
- Duplicate suppression
|
|
539
|
+
|
|
540
|
+
## Interoperability
|
|
541
|
+
|
|
542
|
+
DNS-SD advertisers in the wild vary in how closely they follow the RFCs. This library is intentionally lenient about accepting non-standard responses, while remaining strict about security-relevant parsing.
|
|
543
|
+
|
|
544
|
+
### Accepted (lenient)
|
|
545
|
+
|
|
546
|
+
These advertiser quirks are handled gracefully:
|
|
547
|
+
|
|
548
|
+
| Quirk | Behavior | Seen in |
|
|
549
|
+
|---|---|---|
|
|
550
|
+
| Split responses (PTR in one packet, SRV in another) | Tracks pending FQDNs, resolves when SRV arrives | Normal mDNS behavior (see [resolution lifecycle](#service-resolution-lifecycle)) |
|
|
551
|
+
| Non-zero rcode in responses | Ignored per RFC 6762 §18.11 | Embedded devices |
|
|
552
|
+
| Records in authority section | Processed alongside answers and additionals | Various |
|
|
553
|
+
| Missing TXT record | Service emitted with empty `txt: {}` | Minimal advertisers, Android NSD |
|
|
554
|
+
| Empty TXT record (single `\x00` byte) | Parsed as empty `txt: {}` per RFC 6763 §6.1 | Android NSD |
|
|
555
|
+
| TXT `key=` for null values | Parsed as empty string (Android writes `key=` instead of boolean `key`) | Android NSD (`setAttribute(key, null)`) |
|
|
556
|
+
| Missing A/AAAA records | Service emitted with empty `addresses: []`, updated when they arrive | Normal mDNS behavior (see [resolution lifecycle](#service-resolution-lifecycle)) |
|
|
557
|
+
| Non-zero packet ID | Accepted (RFC 6762 says ID should be 0, but receivers must not require it) | Legacy implementations |
|
|
558
|
+
| Missing AA (authoritative) bit | Accepted | Various |
|
|
559
|
+
| SRV with port 0 | Accepted as-is | Services indicating "not ready" |
|
|
560
|
+
| Non-standard TTL values | Accepted as-is (e.g. Android NSD uses 75-minute / 4500s TTL) | Various, Android NSD |
|
|
561
|
+
| Cache-flush bit missing | Not required for processing | Some minimal advertisers |
|
|
562
|
+
| Mixed-case DNS names | Case-insensitive matching per RFC 1035 §3.1 | Various, Android NSD |
|
|
563
|
+
| Shared hostname across devices | Address resolved from same-packet records | Android NSD 7–12 (hardcoded `Android.local`) |
|
|
564
|
+
| Service name conflict suffix | Parentheses and spaces accepted in instance names per RFC 6763 | Android NSD (`"MyService (2)"`) |
|
|
565
|
+
| Service flickering (goodbye + quick re-announce) | 1-second goodbye grace period absorbs flicker | Android NSD |
|
|
566
|
+
| Long hostnames (40+ bytes) | Accepted up to the 253-char DNS name limit | Android NSD 13+ (`Android_<UUID>.local`) |
|
|
567
|
+
|
|
568
|
+
### Rejected (strict)
|
|
569
|
+
|
|
570
|
+
These are security-relevant and remain strictly enforced:
|
|
571
|
+
|
|
572
|
+
| Check | Why |
|
|
573
|
+
|---|---|
|
|
574
|
+
| QR bit must be 1 (response) | Processing queries as responses would be a spoofing vector |
|
|
575
|
+
| Opcode must be 0 (standard query) | Non-zero opcode means a different DNS operation |
|
|
576
|
+
| Packet must be ≥ 12 bytes | Below DNS header size is always corrupt |
|
|
577
|
+
| Record counts capped at 256/packet | Prevents CPU exhaustion from crafted headers |
|
|
578
|
+
| RDATA must fit within packet | Prevents out-of-bounds reads |
|
|
579
|
+
| DNS names ≤ 253 characters | RFC 1035 §2.3.4 limit, prevents memory abuse |
|
|
580
|
+
| Compression pointer loops detected | Prevents infinite loops (CVE-2006-6870) |
|
|
581
|
+
| Label length ≤ 63 bytes | RFC 1035 limit |
|
|
582
|
+
| Services capped at 1024/browser | Prevents memory exhaustion from flooding |
|
|
583
|
+
|
|
584
|
+
## Security
|
|
585
|
+
|
|
586
|
+
The DNS packet parser and service resolution logic are hardened against attack patterns found in historical CVEs for [Avahi](https://avahi.org/) and Apple's [mDNSResponder](https://opensource.apple.com/projects/mDNSResponder/).
|
|
587
|
+
|
|
588
|
+
**Packet parsing** — All input from the network is validated before processing:
|
|
589
|
+
|
|
590
|
+
- Packets below the 12-byte DNS header minimum are rejected
|
|
591
|
+
- Record counts in the header are capped at 256 per packet to prevent CPU exhaustion from crafted headers claiming thousands of records
|
|
592
|
+
- Each record's RDATA length is validated against the remaining packet bytes before parsing — prevents out-of-bounds reads (CVE-2023-38472 pattern)
|
|
593
|
+
- SRV records require a minimum RDATA length of 7 bytes; TXT string lengths are checked against the RDATA boundary (CVE-2023-38469 pattern)
|
|
594
|
+
- DNS name decompression validates pointer targets are within the packet buffer (CVE-2015-7987 pattern), detects pointer loops with a jump counter (CVE-2006-6870 pattern), and enforces the RFC 1035 §2.3.4 maximum name length of 253 characters
|
|
595
|
+
- Label lengths are validated against both the 63-byte RFC limit and the remaining buffer
|
|
596
|
+
|
|
597
|
+
**Resource limits** — Bounded data structures prevent memory exhaustion from flooding:
|
|
598
|
+
|
|
599
|
+
- Each browser tracks at most 1,024 services. Additional services are silently dropped.
|
|
600
|
+
- The known-answer PTR cache is bounded to the same limit
|
|
601
|
+
- The event buffer caps at 4,096 entries, dropping the oldest on overflow
|
|
602
|
+
|
|
603
|
+
**Response filtering** — The transport layer drops packets that are not valid mDNS responses:
|
|
604
|
+
|
|
605
|
+
- Only response packets are processed (QR bit must be set)
|
|
606
|
+
- Packets with non-zero opcode are dropped (non-standard DNS operations)
|
|
607
|
+
- Query packets with answers in them (a potential spoofing vector) are ignored
|
|
608
|
+
|
|
609
|
+
These defenses are verified by a dedicated security test suite (`test/security.test.js`) that exercises each attack pattern directly.
|
|
610
|
+
|
|
611
|
+
## Comparison with other libraries
|
|
612
|
+
|
|
613
|
+
There are several mDNS/DNS-SD libraries available for Node.js, each with different trade-offs. Here's how they compare:
|
|
614
|
+
|
|
615
|
+
| | dns-sd-browser | [bonjour-service](https://github.com/onlxltd/bonjour-service) | [multicast-dns](https://github.com/mafintosh/multicast-dns) | [dnssd](https://github.com/DeMille/dnssd.js) | [mdns](https://github.com/agnat/node_mdns) |
|
|
616
|
+
|---|---|---|---|---|---|
|
|
617
|
+
| **Browse** | Yes | Yes | Manual | Yes | Yes |
|
|
618
|
+
| **Advertise** | No | Yes | Manual | Yes | Yes |
|
|
619
|
+
| **API style** | Async iterator | EventEmitter | EventEmitter | EventEmitter | EventEmitter |
|
|
620
|
+
| **Dependencies** | 0 | 2 (`multicast-dns`, `fast-deep-equal`) | 2 (`dns-packet`, `thunky`) | 0 | Native (C++) |
|
|
621
|
+
| **TypeScript** | JSDoc types | Written in TS | `@types` available | No | No |
|
|
622
|
+
| **Known-answer suppression** | Yes | No | N/A (low-level) | Yes | System-level |
|
|
623
|
+
| **TTL expiration** | Yes | No | N/A (low-level) | Yes | System-level |
|
|
624
|
+
| **Continuous querying** | Yes (exponential backoff) | Yes (fixed interval) | N/A (low-level) | Yes | System-level |
|
|
625
|
+
| **Node.js** | >= 22 | Any | Any | >= 6 | Any (with native toolchain) |
|
|
626
|
+
| **Last published** | New | Nov 2024 | May 2022 | May 2018 | Nov 2020 |
|
|
627
|
+
|
|
628
|
+
### Notes
|
|
629
|
+
|
|
630
|
+
**[bonjour-service](https://github.com/onlxltd/bonjour-service)** is the most widely used pure-JS option. It provides both browsing and advertising with a simple EventEmitter API. It's a solid, well-maintained choice — especially if you need an advertiser too. However, it doesn't implement known-answer suppression or TTL-based cache expiration, which can lead to duplicate responses and stale services on busy networks.
|
|
631
|
+
|
|
632
|
+
**[multicast-dns](https://github.com/mafintosh/multicast-dns)** is a low-level mDNS library (~14M weekly downloads, mostly as a transitive dependency). It handles DNS packet encoding/decoding and multicast transport, but doesn't implement DNS-SD service browsing — you'd need to build that yourself on top. Great if you need raw mDNS control.
|
|
633
|
+
|
|
634
|
+
**[dnssd](https://github.com/DeMille/dnssd.js)** has the most complete RFC implementation among the pure-JS alternatives, with both browsing and advertising, zero dependencies, and proper known-answer suppression. Unfortunately it hasn't been updated since 2018 and is effectively unmaintained.
|
|
635
|
+
|
|
636
|
+
**[mdns](https://github.com/agnat/node_mdns)** uses native bindings to your OS's mDNS stack (Bonjour/Avahi), giving it the best conformance and performance. The downside is that it requires C++ compilation on install, platform-specific system libraries, and it hasn't been updated since 2020. See the [system mDNS section](#with-a-system-mdns-stack-bonjour-avahi) below for when this trade-off makes sense.
|
|
637
|
+
|
|
638
|
+
**dns-sd-browser** focuses on doing one thing well: browsing. It has no dependencies and implements the querier side of the RFCs thoroughly (known-answer suppression, TTL expiration, cache-flush handling, continuous querying with exponential backoff). The async iterator API avoids common EventEmitter pitfalls like forgotten error handlers. The trade-offs are that it's new and less battle-tested than the alternatives, it requires Node.js 22+, and it only browses — you'll need a separate library if you also need to advertise services.
|
|
639
|
+
|
|
640
|
+
## When to use this library
|
|
641
|
+
|
|
642
|
+
### With a Node.js advertiser (e.g. ciao)
|
|
643
|
+
|
|
644
|
+
This library is designed to run alongside a DNS-SD advertiser like [ciao](https://github.com/homebridge/ciao). A browser and advertiser on the same machine coexist well — they both bind to port 5353 with `SO_REUSEADDR` and receive all multicast traffic. This browser only sends queries and processes responses, while ciao sends responses and processes queries (it also monitors responses for conflict detection, but a browse-only module never announces records, so there is nothing to conflict with). The two RFC 6762 §15 concerns that apply to multiple *queriers* — known-answer list corruption and duplicated queries — don't apply here since only one side is querying. The only minor effect is that a unicast response to the browser's initial QU query may be delivered to ciao's socket instead, but the browser automatically retries via multicast on the next query interval.
|
|
645
|
+
|
|
646
|
+
### With a system mDNS stack (Bonjour, Avahi)
|
|
647
|
+
|
|
648
|
+
**On macOS and Linux**, the operating system already includes a full mDNS implementation (Bonjour on macOS, Avahi on most Linux distributions) that handles both advertising and browsing. Running an additional querier alongside the system stack has some drawbacks, as [RFC 6762 §15](https://www.rfc-editor.org/rfc/rfc6762#section-15) explains:
|
|
649
|
+
|
|
650
|
+
- **Port 5353 conflicts** — when multiple implementations bind to it with `SO_REUSEADDR`, only one receives unicast responses. This forces all queries to use multicast, increasing network traffic.
|
|
651
|
+
- **Known-answer list corruption** — when multiple queriers send simultaneous queries, responders may incorrectly merge their known-answer lists (which are assembled by source IP address), leading to missed answers.
|
|
652
|
+
- **Resource efficiency** — two independent queriers consume extra memory and CPU.
|
|
653
|
+
|
|
654
|
+
If you need a DNS-SD browser that uses the system mDNS on macOS/Linux, consider native bindings like the [`mdns`](https://www.npmjs.com/package/mdns) package. However, `mdns` requires C++ compilation on install and can be difficult to set up on some platforms — particularly Windows.
|
|
655
|
+
|
|
656
|
+
### Best suited for
|
|
657
|
+
|
|
658
|
+
- **Windows** — no system mDNS available
|
|
659
|
+
- **Cross-platform apps** — needs to work everywhere without native compilation
|
|
660
|
+
- **Pairing with ciao** — browser complement to ciao's advertiser, no native dependencies
|
|
661
|
+
- **Environments where system mDNS is absent** — containers, embedded systems, CI runners
|
|
662
|
+
- **Testing and development** — quick setup, no system dependencies
|
|
663
|
+
|
|
664
|
+
## Recommended advertiser
|
|
665
|
+
|
|
666
|
+
This library only browses — it does not advertise services. If you need to publish services on the local network, [@homebridge/ciao](https://github.com/homebridge/ciao) is a well-tested, actively maintained DNS-SD advertiser written in TypeScript. It is RFC 6762/6763 compliant, passes Apple's Bonjour Conformance Test, and is proven in production as part of the Homebridge ecosystem. A browser and advertiser on the same machine coexist well — see [With a Node.js advertiser](#with-a-nodejs-advertiser-eg-ciao) for details.
|
|
667
|
+
|
|
668
|
+
## License
|
|
669
|
+
|
|
670
|
+
MIT
|