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/lib/index.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * dns-sd-browser — Spec-compliant DNS-SD browser over mDNS for Node.js.
3
+ *
4
+ * Discovers services advertised via DNS-SD (RFC 6763) over Multicast DNS
5
+ * (RFC 6762). Designed as a complementary browser to the ciao advertiser.
6
+ *
7
+ * @example
8
+ * ```js
9
+ * import { DnsSdBrowser } from 'dns-sd-browser'
10
+ *
11
+ * const mdns = new DnsSdBrowser()
12
+ * const browser = mdns.browse('_http._tcp')
13
+ *
14
+ * for await (const event of browser) {
15
+ * if (event.type === 'serviceUp') {
16
+ * console.log(`Found: ${event.service.name} at ${event.service.host}:${event.service.port}`)
17
+ * }
18
+ * }
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { MdnsTransport } from './transport.js'
25
+ import { ServiceBrowser, AllServiceBrowser } from './browser.js'
26
+ import { parseServiceType } from './service.js'
27
+ import { SERVICE_TYPE_ENUMERATION, DEFAULT_DOMAIN } from './constants.js'
28
+
29
+ export { ServiceBrowser, AllServiceBrowser } from './browser.js'
30
+
31
+ /**
32
+ * @typedef {import('./service.js').Service} Service
33
+ * @typedef {import('./browser.js').BrowseEvent} BrowseEvent
34
+ */
35
+
36
+ export class DnsSdBrowser {
37
+ /** @type {MdnsTransport} */
38
+ #transport
39
+ /** @type {Set<ServiceBrowser | AllServiceBrowser>} */
40
+ #browsers = new Set()
41
+ #started = false
42
+ #destroyed = false
43
+ /** @type {Promise<void> | null} */
44
+ #startPromise = null
45
+ /** @type {Error | null} */
46
+ #startError = null
47
+
48
+ /**
49
+ * @param {object} [options]
50
+ * @param {number} [options.port=5353] - mDNS port
51
+ * @param {string} [options.interface] - Network interface IP to bind to
52
+ */
53
+ constructor(options = {}) {
54
+ this.#transport = new MdnsTransport({
55
+ port: options.port,
56
+ interface: options.interface,
57
+ })
58
+ }
59
+
60
+ /**
61
+ * Start browsing for a specific service type.
62
+ *
63
+ * Returns a ServiceBrowser that is an async iterable yielding BrowseEvents
64
+ * as services appear, disappear, or update on the network.
65
+ *
66
+ * @param {string | { name: string, protocol?: string }} serviceType
67
+ * Service type to browse for. Either a string like "_http._tcp" or
68
+ * an object like `{ name: 'http', protocol: 'tcp' }`.
69
+ * @param {object} [options]
70
+ * @param {AbortSignal} [options.signal] - Signal to cancel browsing
71
+ * @param {string} [options.subtype] - Browse a service subtype (RFC 6763 §7.1).
72
+ * e.g. `browse('_http._tcp', { subtype: '_printer' })` queries
73
+ * `_printer._sub._http._tcp.local`.
74
+ * @param {number} [options.reconfirmTimeoutMs] - Reconfirmation timeout (ms) per RFC 6762 §10.4
75
+ * @param {number} [options.poofTimeoutMs] - POOF window (ms) per RFC 6762 §10.5
76
+ * @param {number} [options.poofResponseWaitMs] - POOF response wait (ms) per RFC 6762 §10.5
77
+ * @returns {ServiceBrowser}
78
+ */
79
+ browse(serviceType, options = {}) {
80
+ if (this.#destroyed) {
81
+ throw new Error('DnsSdBrowser has been destroyed')
82
+ }
83
+
84
+ const parsed = parseServiceType(serviceType)
85
+
86
+ // Build subtype query name if requested (RFC 6763 §7.1)
87
+ let queryName = parsed.queryName
88
+ if (options.subtype) {
89
+ const sub = options.subtype.startsWith('_') ? options.subtype : `_${options.subtype}`
90
+ queryName = `${sub}._sub.${parsed.type}.${parsed.domain}`
91
+ }
92
+
93
+ this.#ensureStarted()
94
+
95
+ const browser = new ServiceBrowser(this.#transport, {
96
+ queryName,
97
+ serviceType: parsed.type,
98
+ domain: parsed.domain,
99
+ protocol: parsed.protocol,
100
+ signal: options.signal,
101
+ reconfirmTimeoutMs: options.reconfirmTimeoutMs,
102
+ poofTimeoutMs: options.poofTimeoutMs,
103
+ poofResponseWaitMs: options.poofResponseWaitMs,
104
+ onDestroy: () => this.#browsers.delete(browser),
105
+ })
106
+
107
+ this.#browsers.add(browser)
108
+ return browser
109
+ }
110
+
111
+ /**
112
+ * Browse for all service instances on the network, regardless of type.
113
+ *
114
+ * Discovers service types via `_services._dns-sd._udp.local` (RFC 6763 §9),
115
+ * then automatically browses each discovered type for instances. Returns
116
+ * fully resolved BrowseEvents with real host, port, and addresses — the
117
+ * same as `browse()` but across all types.
118
+ *
119
+ * @param {object} [options]
120
+ * @param {AbortSignal} [options.signal]
121
+ * @returns {AllServiceBrowser}
122
+ */
123
+ browseAll(options = {}) {
124
+ if (this.#destroyed) {
125
+ throw new Error('DnsSdBrowser has been destroyed')
126
+ }
127
+
128
+ this.#ensureStarted()
129
+
130
+ const browser = new AllServiceBrowser(this.#transport, {
131
+ domain: DEFAULT_DOMAIN,
132
+ signal: options.signal,
133
+ onDestroy: () => this.#browsers.delete(browser),
134
+ })
135
+
136
+ this.#browsers.add(browser)
137
+ return browser
138
+ }
139
+
140
+ /**
141
+ * Browse for service types on the network.
142
+ *
143
+ * Queries `_services._dns-sd._udp.local` (RFC 6763 §9) to discover
144
+ * which service types are being advertised. Returns lightweight
145
+ * Service objects representing types (not instances) — `host`, `port`,
146
+ * and `addresses` will be empty.
147
+ *
148
+ * For discovering fully resolved service instances across all types,
149
+ * use `browseAll()` instead.
150
+ *
151
+ * @param {object} [options]
152
+ * @param {AbortSignal} [options.signal]
153
+ * @returns {ServiceBrowser}
154
+ */
155
+ browseTypes(options = {}) {
156
+ if (this.#destroyed) {
157
+ throw new Error('DnsSdBrowser has been destroyed')
158
+ }
159
+
160
+ this.#ensureStarted()
161
+
162
+ const browser = new ServiceBrowser(this.#transport, {
163
+ queryName: SERVICE_TYPE_ENUMERATION,
164
+ serviceType: '_services._dns-sd._udp',
165
+ domain: DEFAULT_DOMAIN,
166
+ protocol: 'udp',
167
+ isTypeEnumeration: true,
168
+ signal: options.signal,
169
+ onDestroy: () => this.#browsers.delete(browser),
170
+ })
171
+
172
+ this.#browsers.add(browser)
173
+ return browser
174
+ }
175
+
176
+ /**
177
+ * Re-join multicast groups and restart all browsers.
178
+ *
179
+ * Call this after a network interface change (e.g. WiFi reconnect,
180
+ * Ethernet re-plug). The OS drops multicast group membership when an
181
+ * interface goes down; this method re-establishes it and flushes stale
182
+ * service state.
183
+ *
184
+ * All current services are emitted as `serviceDown` events, and
185
+ * querying restarts with the initial rapid schedule so services on
186
+ * the new network are discovered quickly.
187
+ */
188
+ rejoin() {
189
+ if (this.#destroyed) throw new Error('DnsSdBrowser has been destroyed')
190
+ if (!this.#started) return
191
+
192
+ this.#transport.rejoinMulticast()
193
+ for (const browser of this.#browsers) {
194
+ browser.resetNetwork()
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Stop all browsers and close the mDNS transport.
200
+ * @returns {Promise<void>}
201
+ */
202
+ async destroy() {
203
+ if (this.#destroyed) return
204
+ this.#destroyed = true
205
+
206
+ for (const browser of this.#browsers) {
207
+ browser.destroy()
208
+ }
209
+ this.#browsers.clear()
210
+
211
+ // Wait for the transport to finish starting before closing it,
212
+ // to avoid closing a socket that is still in the process of binding.
213
+ if (this.#startPromise) {
214
+ await this.#startPromise.catch(() => {})
215
+ }
216
+
217
+ await this.#transport.destroy()
218
+ }
219
+
220
+ /** @returns {Promise<void>} */
221
+ async [Symbol.asyncDispose]() {
222
+ return this.destroy()
223
+ }
224
+
225
+ /**
226
+ * Returns a promise that resolves when the mDNS transport is ready
227
+ * to send and receive packets. Useful for tests or when you need
228
+ * to ensure the socket is bound before external interaction.
229
+ *
230
+ * Note: the transport is started lazily on the first `browse()` or
231
+ * `browseAll()` call. Calling `ready()` before any browse will throw.
232
+ * @returns {Promise<void>}
233
+ */
234
+ async ready() {
235
+ if (!this.#startPromise) {
236
+ throw new Error('Cannot call ready() before browse() or browseAll() — transport not started')
237
+ }
238
+ await this.#startPromise
239
+ if (this.#startError) throw this.#startError
240
+ }
241
+
242
+ /** Start the transport if not already started. */
243
+ #ensureStarted() {
244
+ if (!this.#started) {
245
+ this.#started = true
246
+ this.#startPromise = this.#transport.start().catch((err) => {
247
+ /* c8 ignore next -- requires a transport bind failure */
248
+ this.#startError = err
249
+ })
250
+ }
251
+ }
252
+ }
package/lib/service.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Service data type for discovered DNS-SD services.
3
+ * @module
4
+ */
5
+
6
+ /**
7
+ * @typedef {object} Service
8
+ * @property {string} name - Instance name (e.g. "My Printer")
9
+ * @property {string} type - Service type (e.g. "_http._tcp")
10
+ * @property {string} protocol - Transport protocol ("tcp" or "udp")
11
+ * @property {string} domain - Domain (default "local")
12
+ * @property {string} host - Target hostname (e.g. "printer.local")
13
+ * @property {number} port - Port number
14
+ * @property {string[]} addresses - IPv4 and IPv6 addresses
15
+ * @property {Record<string, string | true>} txt - Parsed TXT key-value pairs
16
+ * @property {Record<string, Uint8Array>} txtRaw - Raw TXT record values
17
+ * @property {string} fqdn - Fully qualified service name
18
+ * @property {string[]} subtypes - Service subtypes
19
+ * @property {number} updatedAt - Timestamp of last update (ms)
20
+ */
21
+
22
+ /**
23
+ * Parse TXT record data (array of Uint8Array strings) into key-value pairs.
24
+ *
25
+ * Per RFC 6763 §6.3:
26
+ * - Format is "key=value" where value is the part after the first '='
27
+ * - Keys without '=' are boolean flags (value is `true`)
28
+ * - Duplicate keys: only the first occurrence is used
29
+ * - Keys are case-insensitive but we preserve original casing
30
+ *
31
+ * @param {Uint8Array[]} txtData
32
+ * @returns {{ txt: Record<string, string | true>, txtRaw: Record<string, Uint8Array> }}
33
+ */
34
+ export function parseTxtData(txtData) {
35
+ /** @type {Record<string, string | true>} */
36
+ const txt = {}
37
+ /** @type {Record<string, Uint8Array>} */
38
+ const txtRaw = {}
39
+ /** @type {Set<string>} - Lowercase keys seen so far for duplicate detection */
40
+ const seenKeys = new Set()
41
+ const decoder = new TextDecoder()
42
+
43
+ for (const entry of txtData) {
44
+ const str = decoder.decode(entry)
45
+ const eqIndex = str.indexOf('=')
46
+
47
+ // RFC 6763 §6.4: strings beginning with '=' (missing key) MUST be silently ignored
48
+ if (eqIndex === 0) continue
49
+
50
+ if (eqIndex === -1) {
51
+ // Boolean flag — key present without value (RFC 6763 §6.4)
52
+ const key = str
53
+ const keyLower = key.toLowerCase()
54
+ if (!seenKeys.has(keyLower)) {
55
+ seenKeys.add(keyLower)
56
+ txt[key] = true
57
+ txtRaw[key] = entry
58
+ }
59
+ } else {
60
+ const key = str.slice(0, eqIndex)
61
+ const value = str.slice(eqIndex + 1)
62
+ const keyLower = key.toLowerCase()
63
+ // Only first occurrence of a key is valid (RFC 6763 §6.4)
64
+ if (!seenKeys.has(keyLower)) {
65
+ seenKeys.add(keyLower)
66
+ txt[key] = value
67
+ txtRaw[key] = entry.slice(eqIndex + 1)
68
+ }
69
+ }
70
+ }
71
+
72
+ return { txt, txtRaw }
73
+ }
74
+
75
+ /**
76
+ * Parse a service type string into its components.
77
+ *
78
+ * Accepts either:
79
+ * - Full form: "_http._tcp" or "_http._tcp.local"
80
+ * - Object form: { name: "http", protocol: "tcp" }
81
+ *
82
+ * @param {string | { name: string, protocol?: string }} serviceType
83
+ * @returns {{ type: string, protocol: string, domain: string, queryName: string }}
84
+ */
85
+ export function parseServiceType(serviceType) {
86
+ if (typeof serviceType === 'object') {
87
+ if (!serviceType || typeof serviceType.name !== 'string' || !serviceType.name) {
88
+ throw new Error('Service type object must have a non-empty "name" property')
89
+ }
90
+ const name = serviceType.name.startsWith('_')
91
+ ? serviceType.name
92
+ : `_${serviceType.name}`
93
+ const protocol = serviceType.protocol || 'tcp'
94
+ const proto = protocol.startsWith('_') ? protocol : `_${protocol}`
95
+ const type = `${name}.${proto}`
96
+ return {
97
+ type,
98
+ protocol: proto.slice(1),
99
+ domain: 'local',
100
+ queryName: `${type}.local`,
101
+ }
102
+ }
103
+
104
+ if (typeof serviceType !== 'string' || !serviceType) {
105
+ throw new Error('Service type must be a non-empty string (e.g. "_http._tcp") or an object { name, protocol? }')
106
+ }
107
+
108
+ // String form: "_http._tcp" or "_http._tcp.local"
109
+ const parts = serviceType.split('.')
110
+ let type, domain
111
+
112
+ if (parts.length >= 3 && parts[parts.length - 1] === 'local') {
113
+ // "_http._tcp.local" → type = "_http._tcp", domain = "local"
114
+ domain = parts[parts.length - 1]
115
+ type = parts.slice(0, -1).join('.')
116
+ } else {
117
+ // "_http._tcp" → default domain "local"
118
+ type = serviceType
119
+ domain = 'local'
120
+ }
121
+
122
+ // Extract protocol from type (second label, defaults to tcp)
123
+ const typeLabels = type.split('.')
124
+ const protocol = typeLabels.length >= 2 ? typeLabels[1].replace(/^_/, '') : 'tcp'
125
+
126
+ return {
127
+ type,
128
+ protocol,
129
+ domain,
130
+ queryName: `${type}.${domain}`,
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Extract the instance name from a fully qualified service name.
136
+ *
137
+ * Given "My Service._http._tcp.local" and type "_http._tcp",
138
+ * returns "My Service".
139
+ *
140
+ * @param {string} fqdn
141
+ * @param {string} serviceType - e.g. "_http._tcp.local"
142
+ * @returns {string}
143
+ */
144
+ export function extractInstanceName(fqdn, serviceType) {
145
+ // The instance name is everything before the service type
146
+ const suffix = '.' + serviceType
147
+ if (fqdn.endsWith(suffix)) {
148
+ return fqdn.slice(0, -suffix.length)
149
+ }
150
+ // Fallback: return the full fqdn
151
+ return fqdn
152
+ }