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/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
|
+
}
|