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.
@@ -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
+ }