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/lib/transport.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mDNS transport layer — manages the UDP multicast sockets.
|
|
3
|
+
*
|
|
4
|
+
* Handles binding to the mDNS port, joining/leaving multicast groups for
|
|
5
|
+
* both IPv4 (224.0.0.251) and IPv6 (FF02::FB), sending queries, and
|
|
6
|
+
* dispatching received packets to registered handlers.
|
|
7
|
+
* Multiple ServiceBrowsers share a single transport instance via the
|
|
8
|
+
* DnsSdBrowser class.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createSocket } from 'node:dgram'
|
|
14
|
+
import { MDNS_ADDRESS, MDNS_ADDRESS_V6, MDNS_PORT, MDNS_TTL } from './constants.js'
|
|
15
|
+
import * as dns from './dns.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @callback PacketHandler
|
|
19
|
+
* @param {dns.DnsPacket} packet
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export class MdnsTransport {
|
|
24
|
+
/** @type {import('node:dgram').Socket | null} */
|
|
25
|
+
#socket4 = null
|
|
26
|
+
/** @type {import('node:dgram').Socket | null} */
|
|
27
|
+
#socket6 = null
|
|
28
|
+
#port
|
|
29
|
+
#interface
|
|
30
|
+
/** @type {Set<PacketHandler>} */
|
|
31
|
+
#handlers = new Set()
|
|
32
|
+
/** @type {Set<PacketHandler>} */
|
|
33
|
+
#queryHandlers = new Set()
|
|
34
|
+
#bound = false
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} [options]
|
|
38
|
+
* @param {number} [options.port] - mDNS port (default 5353)
|
|
39
|
+
* @param {string} [options.interface] - Network interface IP to bind to
|
|
40
|
+
*/
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
this.#port = options.port ?? MDNS_PORT
|
|
43
|
+
this.#interface = options.interface
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start the transport: bind sockets and join multicast groups.
|
|
48
|
+
* Always binds IPv4. Attempts IPv6 but does not fail if unavailable.
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
async start() {
|
|
52
|
+
if (this.#bound) return
|
|
53
|
+
|
|
54
|
+
// Always start IPv4
|
|
55
|
+
await this.#startIPv4()
|
|
56
|
+
|
|
57
|
+
// Attempt IPv6 — non-fatal if it fails (e.g. no IPv6 on the host)
|
|
58
|
+
try {
|
|
59
|
+
await this.#startIPv6()
|
|
60
|
+
} catch {
|
|
61
|
+
/* c8 ignore next 2 -- IPv6 start failure requires OS-level IPv6 unavailability, which is hard to replicate in tests but expected in some environments */
|
|
62
|
+
// IPv6 not available on this host — IPv4-only mode
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bind the IPv4 socket and join the multicast group.
|
|
68
|
+
* @returns {Promise<void>}
|
|
69
|
+
*/
|
|
70
|
+
async #startIPv4() {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
this.#socket4 = createSocket({ type: 'udp4', reuseAddr: true })
|
|
73
|
+
|
|
74
|
+
/* c8 ignore next 5 -- IPv4 socket error requires OS-level bind failure */
|
|
75
|
+
this.#socket4.on('error', (err) => {
|
|
76
|
+
if (!this.#bound) {
|
|
77
|
+
reject(err)
|
|
78
|
+
}
|
|
79
|
+
// After binding, log but don't crash (transient errors are normal)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
this.#socket4.on('message', (msg, rinfo) => this.#onMessage(msg, rinfo))
|
|
83
|
+
|
|
84
|
+
this.#socket4.bind(this.#port, () => {
|
|
85
|
+
const socket = /** @type {import('node:dgram').Socket} */ (this.#socket4)
|
|
86
|
+
try {
|
|
87
|
+
const iface = this.#interface || '0.0.0.0'
|
|
88
|
+
socket.addMembership(MDNS_ADDRESS, iface)
|
|
89
|
+
socket.setMulticastLoopback(true)
|
|
90
|
+
socket.setMulticastTTL(MDNS_TTL)
|
|
91
|
+
if (this.#interface) {
|
|
92
|
+
socket.setMulticastInterface(this.#interface)
|
|
93
|
+
}
|
|
94
|
+
/* c8 ignore start -- multicast setup failure requires specific OS/interface conditions */
|
|
95
|
+
} catch {
|
|
96
|
+
// Multicast setup can fail on some interfaces — continue anyway.
|
|
97
|
+
// The socket can still receive unicast responses.
|
|
98
|
+
}
|
|
99
|
+
/* c8 ignore stop */
|
|
100
|
+
this.#bound = true
|
|
101
|
+
resolve()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bind the IPv6 socket and join the multicast group.
|
|
108
|
+
* @returns {Promise<void>}
|
|
109
|
+
*/
|
|
110
|
+
async #startIPv6() {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
this.#socket6 = createSocket({ type: 'udp6', reuseAddr: true })
|
|
113
|
+
|
|
114
|
+
this.#socket6.on('error', (err) => {
|
|
115
|
+
/* c8 ignore start -- hard to replicate IPv6 bind errors in tests, but they are expected in some environments */
|
|
116
|
+
// IPv6 socket errors are always non-fatal — clean up and resolve/reject
|
|
117
|
+
// to avoid hanging the Promise. This covers EAFNOSUPPORT (no IPv6 on host),
|
|
118
|
+
// EADDRINUSE, and any other bind errors.
|
|
119
|
+
if (this.#socket6) {
|
|
120
|
+
try { this.#socket6.close() } catch { /* ignore */ }
|
|
121
|
+
this.#socket6 = null
|
|
122
|
+
}
|
|
123
|
+
reject(err)
|
|
124
|
+
/* c8 ignore stop */
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
this.#socket6.on('message', (msg, rinfo) => this.#onMessage(msg, rinfo))
|
|
128
|
+
|
|
129
|
+
/* c8 ignore next 13 -- IPv6 bind/multicast requires IPv6 availability */
|
|
130
|
+
this.#socket6.bind(this.#port, () => {
|
|
131
|
+
const socket = /** @type {import('node:dgram').Socket} */ (this.#socket6)
|
|
132
|
+
try {
|
|
133
|
+
socket.addMembership(MDNS_ADDRESS_V6)
|
|
134
|
+
socket.setMulticastLoopback(true)
|
|
135
|
+
socket.setMulticastTTL(MDNS_TTL)
|
|
136
|
+
} catch {
|
|
137
|
+
// IPv6 multicast setup failed — close the socket, fall back to IPv4 only
|
|
138
|
+
socket.close()
|
|
139
|
+
this.#socket6 = null
|
|
140
|
+
}
|
|
141
|
+
resolve()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Handle an incoming message from either socket.
|
|
148
|
+
* @param {Buffer} msg
|
|
149
|
+
* @param {import('node:dgram').RemoteInfo} rinfo
|
|
150
|
+
*/
|
|
151
|
+
#onMessage(msg, rinfo) {
|
|
152
|
+
try {
|
|
153
|
+
// RFC 6762 §11: Source address check (defense-in-depth).
|
|
154
|
+
// All packets received on our multicast socket are inherently local-link
|
|
155
|
+
// per RFC 6762 §11 ("packets received via link-local multicast are
|
|
156
|
+
// necessarily deemed to have originated on the local link"). However, we
|
|
157
|
+
// reject clearly invalid source addresses as a defensive measure.
|
|
158
|
+
if (rinfo.address === '0.0.0.0' || rinfo.address === '::') return
|
|
159
|
+
|
|
160
|
+
// RFC 6762 §6: source UDP port in all mDNS traffic MUST be 5353.
|
|
161
|
+
// Silently ignore packets from other ports.
|
|
162
|
+
if (rinfo.port !== this.#port) return
|
|
163
|
+
|
|
164
|
+
const packet = dns.decode(msg)
|
|
165
|
+
// Ignore packets with non-zero opcode (RFC 6762 §18.3)
|
|
166
|
+
if (packet.flags.opcode !== 0) return
|
|
167
|
+
|
|
168
|
+
if (!packet.flags.qr) {
|
|
169
|
+
// Query packet (QR=0) — dispatch to query handlers for
|
|
170
|
+
// duplicate question suppression (RFC 6762 §7.3) and
|
|
171
|
+
// Passive Observation of Failures (RFC 6762 §10.5)
|
|
172
|
+
for (const handler of this.#queryHandlers) {
|
|
173
|
+
try {
|
|
174
|
+
handler(packet)
|
|
175
|
+
/* c8 ignore start -- handler error isolation is defensive */
|
|
176
|
+
} catch {
|
|
177
|
+
// Isolate handler failures so one bad handler can't break others
|
|
178
|
+
}
|
|
179
|
+
/* c8 ignore stop */
|
|
180
|
+
}
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Response packet (QR=1)
|
|
185
|
+
// Note: rcode is intentionally NOT checked. RFC 6762 §18.11 says
|
|
186
|
+
// receivers SHOULD silently ignore the rcode field, and some buggy
|
|
187
|
+
// advertisers set non-zero rcodes in otherwise valid responses.
|
|
188
|
+
|
|
189
|
+
for (const handler of this.#handlers) {
|
|
190
|
+
handler(packet)
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Silently ignore malformed packets (RFC 6762 §18)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Send an mDNS query on IPv4 (and IPv6 if available).
|
|
199
|
+
* @param {dns.QueryOptions} queryOptions
|
|
200
|
+
* @returns {Promise<void>}
|
|
201
|
+
*/
|
|
202
|
+
async sendQuery(queryOptions) {
|
|
203
|
+
/* c8 ignore next 3 -- guard requires calling sendQuery before start() */
|
|
204
|
+
if (!this.#socket4 || !this.#bound) {
|
|
205
|
+
throw new Error('Transport not started')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Encode into one or more packets (splits known answers if too large)
|
|
209
|
+
const packets = dns.encodeQueryPackets(queryOptions)
|
|
210
|
+
|
|
211
|
+
for (const buf of packets) {
|
|
212
|
+
// Send on IPv4
|
|
213
|
+
await this.#sendOn(this.#socket4, buf, MDNS_ADDRESS)
|
|
214
|
+
|
|
215
|
+
// Also send on IPv6 if available
|
|
216
|
+
/* c8 ignore next 7 -- IPv6 send path requires IPv6 socket availability */
|
|
217
|
+
if (this.#socket6) {
|
|
218
|
+
try {
|
|
219
|
+
await this.#sendOn(this.#socket6, buf, MDNS_ADDRESS_V6)
|
|
220
|
+
} catch {
|
|
221
|
+
// IPv6 send failure is non-fatal
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Send a buffer on a specific socket.
|
|
229
|
+
* @param {import('node:dgram').Socket} socket
|
|
230
|
+
* @param {Buffer} buf
|
|
231
|
+
* @param {string} address
|
|
232
|
+
* @returns {Promise<void>}
|
|
233
|
+
*/
|
|
234
|
+
#sendOn(socket, buf, address) {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
socket.send(buf, 0, buf.length, this.#port, address, (err) => {
|
|
237
|
+
if (err) reject(err)
|
|
238
|
+
else resolve()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Register a handler for incoming mDNS response packets.
|
|
245
|
+
* @param {PacketHandler} handler
|
|
246
|
+
*/
|
|
247
|
+
addHandler(handler) {
|
|
248
|
+
this.#handlers.add(handler)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Remove a previously registered handler.
|
|
253
|
+
* @param {PacketHandler} handler
|
|
254
|
+
*/
|
|
255
|
+
removeHandler(handler) {
|
|
256
|
+
this.#handlers.delete(handler)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Register a handler for incoming mDNS query packets (QR=0).
|
|
261
|
+
* Used for duplicate question suppression (RFC 6762 §7.3) and
|
|
262
|
+
* Passive Observation of Failures (RFC 6762 §10.5).
|
|
263
|
+
* @param {PacketHandler} handler
|
|
264
|
+
*/
|
|
265
|
+
addQueryHandler(handler) {
|
|
266
|
+
this.#queryHandlers.add(handler)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Remove a previously registered query handler.
|
|
271
|
+
* @param {PacketHandler} handler
|
|
272
|
+
*/
|
|
273
|
+
removeQueryHandler(handler) {
|
|
274
|
+
this.#queryHandlers.delete(handler)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Re-join multicast groups on existing sockets.
|
|
279
|
+
*
|
|
280
|
+
* Call this after a network interface change (e.g. WiFi reconnect,
|
|
281
|
+
* Ethernet re-plug). The OS drops multicast group membership when an
|
|
282
|
+
* interface goes down; this method re-establishes it so the socket
|
|
283
|
+
* can receive multicast responses again.
|
|
284
|
+
*/
|
|
285
|
+
rejoinMulticast() {
|
|
286
|
+
if (this.#socket4) {
|
|
287
|
+
try {
|
|
288
|
+
const iface = this.#interface || '0.0.0.0'
|
|
289
|
+
// Drop first to avoid EADDRINUSE if membership somehow survived
|
|
290
|
+
try { this.#socket4.dropMembership(MDNS_ADDRESS, iface) } catch { /* ignore */ }
|
|
291
|
+
this.#socket4.addMembership(MDNS_ADDRESS, iface)
|
|
292
|
+
if (this.#interface) {
|
|
293
|
+
this.#socket4.setMulticastInterface(this.#interface)
|
|
294
|
+
}
|
|
295
|
+
/* c8 ignore start -- multicast re-join failure requires OS/interface conditions */
|
|
296
|
+
} catch {
|
|
297
|
+
// Multicast re-join failed — interface may not be fully up yet
|
|
298
|
+
}
|
|
299
|
+
/* c8 ignore stop */
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* c8 ignore next 8 -- IPv6 rejoin requires IPv6 socket availability */
|
|
303
|
+
if (this.#socket6) {
|
|
304
|
+
try {
|
|
305
|
+
try { this.#socket6.dropMembership(MDNS_ADDRESS_V6) } catch { /* ignore */ }
|
|
306
|
+
this.#socket6.addMembership(MDNS_ADDRESS_V6)
|
|
307
|
+
} catch {
|
|
308
|
+
// IPv6 multicast re-join failed — non-fatal
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Close all sockets and clean up.
|
|
315
|
+
* @returns {Promise<void>}
|
|
316
|
+
*/
|
|
317
|
+
async destroy() {
|
|
318
|
+
this.#handlers.clear()
|
|
319
|
+
this.#queryHandlers.clear()
|
|
320
|
+
const promises = []
|
|
321
|
+
|
|
322
|
+
if (this.#socket4) {
|
|
323
|
+
const s4 = this.#socket4
|
|
324
|
+
this.#socket4 = null
|
|
325
|
+
promises.push(/** @type {Promise<void>} */ (new Promise((resolve) => {
|
|
326
|
+
s4.close(() => resolve())
|
|
327
|
+
})))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* c8 ignore next 7 -- IPv6 socket cleanup requires IPv6 availability */
|
|
331
|
+
if (this.#socket6) {
|
|
332
|
+
const s6 = this.#socket6
|
|
333
|
+
this.#socket6 = null
|
|
334
|
+
promises.push(/** @type {Promise<void>} */ (new Promise((resolve) => {
|
|
335
|
+
s6.close(() => resolve())
|
|
336
|
+
})))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.#bound = false
|
|
340
|
+
|
|
341
|
+
if (promises.length > 0) {
|
|
342
|
+
await Promise.all(promises)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dns-sd-browser",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Spec-compliant DNS-SD browser over mDNS for Node.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./lib/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"lib",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "c8 node --test test/**/*.test.js",
|
|
16
|
+
"test:ci": "c8 --reporter=text --reporter=lcov node --test test/**/*.test.js",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"build": "tsc -p tsconfig.build.json",
|
|
19
|
+
"prepack": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"dns-sd",
|
|
23
|
+
"mdns",
|
|
24
|
+
"bonjour",
|
|
25
|
+
"zeroconf",
|
|
26
|
+
"service-discovery",
|
|
27
|
+
"multicast-dns"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Gregor MacLennan",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/gmaclennan/dns-sd-browser.git"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.19.15",
|
|
40
|
+
"c8": "^11.0.0",
|
|
41
|
+
"dns-packet": "^5.6.1",
|
|
42
|
+
"typescript": "^5.7.0"
|
|
43
|
+
}
|
|
44
|
+
}
|