@corewire/core 0.0.0 → 1.0.0-beta.1

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,763 @@
1
+ import { r as __require } from "./chunk-wE0WeVzl.mjs";
2
+ import { l as DiscoveryError } from "./errors-DVv1n4fL.mjs";
3
+ import { t as Logger } from "./reporter-DfM6vPT0.mjs";
4
+ import { t as waitFor } from "./utils-DY5OKUuy.mjs";
5
+ import { createSocket } from "node:dgram";
6
+ import { createConnection } from "node:net";
7
+ import { networkInterfaces } from "node:os";
8
+
9
+ //#region src/discovery/mdns.ts
10
+ /** The IPv4 multicast address of mDNS, from RFC 6762. */
11
+ const MDNS_ADDRESS$1 = "224.0.0.251";
12
+ const MDNS_PORT$1 = 5353;
13
+ const QUERY_ID = 13823;
14
+ /** The number of service questions in one DNS message. */
15
+ const SERVICES_PER_MSG = 3;
16
+ /** The DNS record types that discovery asks for and reads. */
17
+ const QueryType = {
18
+ A: 1,
19
+ PTR: 12,
20
+ TXT: 16,
21
+ AAAA: 28,
22
+ SRV: 33,
23
+ ANY: 255
24
+ };
25
+ /**
26
+ * Encodes a domain name as DNS labels, each prefixed with its length and the last followed
27
+ * by a zero byte. A label longer than 63 bytes is cut off.
28
+ */
29
+ function encodeQName(name) {
30
+ const parts = [];
31
+ const labels = splitServiceName(name);
32
+ for (const label of labels) {
33
+ const encoded = Buffer.from(label, "utf-8");
34
+ if (encoded.byteLength > 63) parts.push(Buffer.from([63]), encoded.subarray(0, 63));
35
+ else parts.push(Buffer.from([encoded.byteLength]), encoded);
36
+ }
37
+ parts.push(Buffer.from([0]));
38
+ return Buffer.concat(parts);
39
+ }
40
+ /**
41
+ * Splits a service name into DNS labels. An instance name can contain dots, so the split
42
+ * happens at the service type: `Living Room._hap._tcp.local` gives
43
+ * `['Living Room', '_hap', '_tcp', 'local']`.
44
+ */
45
+ function splitServiceName(name) {
46
+ const match = name.match(/\._[a-z0-9-]+\._(?:tcp|udp)\.local$/);
47
+ if (match) return [name.substring(0, match.index), ...match[0].substring(1).split(".")];
48
+ return name.split(".");
49
+ }
50
+ /**
51
+ * Encodes the 12-byte DNS header.
52
+ */
53
+ function encodeDnsHeader(header) {
54
+ const buf = Buffer.allocUnsafe(12);
55
+ buf.writeUInt16BE(header.id, 0);
56
+ buf.writeUInt16BE(header.flags, 2);
57
+ buf.writeUInt16BE(header.qdcount, 4);
58
+ buf.writeUInt16BE(header.ancount, 6);
59
+ buf.writeUInt16BE(header.nscount, 8);
60
+ buf.writeUInt16BE(header.arcount, 10);
61
+ return buf;
62
+ }
63
+ /**
64
+ * Encodes one DNS question.
65
+ *
66
+ * @param unicastResponse - Sets the QU bit, which asks for a unicast response.
67
+ */
68
+ function encodeDnsQuestion(name, qtype, unicastResponse = false) {
69
+ const qname = encodeQName(name);
70
+ const suffix = Buffer.allocUnsafe(4);
71
+ suffix.writeUInt16BE(qtype, 0);
72
+ suffix.writeUInt16BE(unicastResponse ? 32769 : 1, 2);
73
+ return Buffer.concat([qname, suffix]);
74
+ }
75
+ /**
76
+ * Builds the DNS query packets for the service types, {@link SERVICES_PER_MSG} questions per packet.
77
+ *
78
+ * @param services - The mDNS service types, such as `_hap._tcp.local`.
79
+ * @param unicastResponse - Sets the QU bit, which asks for a unicast response.
80
+ */
81
+ function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = false) {
82
+ const packets = [];
83
+ for (let i = 0; i < services.length; i += SERVICES_PER_MSG) {
84
+ const chunk = services.slice(i, i + SERVICES_PER_MSG);
85
+ const questions = chunk.map((service) => encodeDnsQuestion(service, qtype, unicastResponse));
86
+ const header = encodeDnsHeader({
87
+ id: QUERY_ID,
88
+ flags: 0,
89
+ qdcount: chunk.length,
90
+ ancount: 0,
91
+ nscount: 0,
92
+ arcount: 0
93
+ });
94
+ packets.push(Buffer.concat([header, ...questions]));
95
+ }
96
+ return packets;
97
+ }
98
+ /** The number of compression pointers to follow in one name, so a pointer loop ends. */
99
+ const MAX_POINTER_JUMPS = 128;
100
+ /**
101
+ * Decodes a DNS name and follows its compression pointers (RFC 1035 section 4.1.4).
102
+ *
103
+ * @returns The name and the offset after it in the packet.
104
+ */
105
+ function decodeQName(buf, offset) {
106
+ const labels = [];
107
+ let currentOffset = offset;
108
+ let jumped = false;
109
+ let jumps = 0;
110
+ let returnOffset = offset;
111
+ while (currentOffset < buf.byteLength) {
112
+ const length = buf[currentOffset];
113
+ if (length === 0) {
114
+ if (!jumped) returnOffset = currentOffset + 1;
115
+ break;
116
+ }
117
+ if ((length & 192) === 192) {
118
+ if (++jumps > MAX_POINTER_JUMPS) break;
119
+ const pointer = (length & 63) << 8 | buf[currentOffset + 1];
120
+ if (!jumped) returnOffset = currentOffset + 2;
121
+ currentOffset = pointer;
122
+ jumped = true;
123
+ continue;
124
+ }
125
+ currentOffset++;
126
+ if (currentOffset + length > buf.byteLength) break;
127
+ labels.push(buf.toString("utf-8", currentOffset, currentOffset + length));
128
+ currentOffset += length;
129
+ if (!jumped) returnOffset = currentOffset;
130
+ }
131
+ return [labels.join("."), returnOffset];
132
+ }
133
+ /**
134
+ * Decodes the 12-byte DNS header.
135
+ */
136
+ function decodeDnsHeader(buf) {
137
+ return {
138
+ id: buf.readUInt16BE(0),
139
+ flags: buf.readUInt16BE(2),
140
+ qdcount: buf.readUInt16BE(4),
141
+ ancount: buf.readUInt16BE(6),
142
+ nscount: buf.readUInt16BE(8),
143
+ arcount: buf.readUInt16BE(10)
144
+ };
145
+ }
146
+ /**
147
+ * Decodes one DNS question.
148
+ *
149
+ * @returns The question and the offset after it.
150
+ */
151
+ function decodeQuestion(buf, offset) {
152
+ const [qname, newOffset] = decodeQName(buf, offset);
153
+ return [{
154
+ qname,
155
+ qtype: buf.readUInt16BE(newOffset),
156
+ qclass: buf.readUInt16BE(newOffset + 2)
157
+ }, newOffset + 4];
158
+ }
159
+ /**
160
+ * Decodes the data of a TXT record into properties. Each entry is a length-prefixed UTF-8
161
+ * `key=value` string; an entry without `=` gets an empty value.
162
+ */
163
+ function decodeTxtRecord(buf, offset, length) {
164
+ const properties = {};
165
+ let pos = offset;
166
+ const end = offset + length;
167
+ while (pos < end) {
168
+ const entryLength = buf[pos];
169
+ pos++;
170
+ if (entryLength === 0 || pos + entryLength > end) break;
171
+ const entry = buf.toString("utf-8", pos, pos + entryLength);
172
+ pos += entryLength;
173
+ const eqIndex = entry.indexOf("=");
174
+ if (eqIndex >= 0) properties[entry.substring(0, eqIndex)] = entry.substring(eqIndex + 1);
175
+ else properties[entry] = "";
176
+ }
177
+ return properties;
178
+ }
179
+ /**
180
+ * Decodes the data of an SRV record.
181
+ */
182
+ function decodeSrvRecord(buf, offset) {
183
+ const priority = buf.readUInt16BE(offset);
184
+ const weight = buf.readUInt16BE(offset + 2);
185
+ const port = buf.readUInt16BE(offset + 4);
186
+ const [target] = decodeQName(buf, offset + 6);
187
+ return {
188
+ priority,
189
+ weight,
190
+ port,
191
+ target
192
+ };
193
+ }
194
+ /**
195
+ * Decodes one resource record. The data of A, AAAA, PTR, SRV and TXT records is parsed;
196
+ * other types keep their raw bytes.
197
+ *
198
+ * @returns The record and the offset after it, or null when the packet ends inside the record header.
199
+ */
200
+ function decodeResource(buf, offset) {
201
+ const [qname, nameEnd] = decodeQName(buf, offset);
202
+ if (nameEnd + 10 > buf.byteLength) return null;
203
+ const qtype = buf.readUInt16BE(nameEnd);
204
+ const qclass = buf.readUInt16BE(nameEnd + 2);
205
+ const ttl = buf.readUInt32BE(nameEnd + 4);
206
+ const rdLength = buf.readUInt16BE(nameEnd + 8);
207
+ const rdOffset = nameEnd + 10;
208
+ let rdata;
209
+ switch (qtype) {
210
+ case QueryType.A:
211
+ rdata = `${buf[rdOffset]}.${buf[rdOffset + 1]}.${buf[rdOffset + 2]}.${buf[rdOffset + 3]}`;
212
+ break;
213
+ case QueryType.AAAA: {
214
+ const parts = [];
215
+ for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(rdOffset + i * 2).toString(16));
216
+ rdata = parts.join(":");
217
+ break;
218
+ }
219
+ case QueryType.PTR: {
220
+ const [name] = decodeQName(buf, rdOffset);
221
+ rdata = name;
222
+ break;
223
+ }
224
+ case QueryType.SRV:
225
+ rdata = decodeSrvRecord(buf, rdOffset);
226
+ break;
227
+ case QueryType.TXT:
228
+ rdata = decodeTxtRecord(buf, rdOffset, rdLength);
229
+ break;
230
+ default: rdata = buf.subarray(rdOffset, rdOffset + rdLength);
231
+ }
232
+ return [{
233
+ qname,
234
+ qtype,
235
+ qclass,
236
+ ttl,
237
+ rdata
238
+ }, rdOffset + rdLength];
239
+ }
240
+ /**
241
+ * Decodes a DNS response into its header, answers and additional records. The question and
242
+ * authority sections are skipped.
243
+ */
244
+ function decodeDnsResponse(buf) {
245
+ const header = decodeDnsHeader(buf);
246
+ let offset = 12;
247
+ for (let i = 0; i < header.qdcount; i++) {
248
+ const [, newOffset] = decodeQuestion(buf, offset);
249
+ offset = newOffset;
250
+ }
251
+ const answers = [];
252
+ for (let i = 0; i < header.ancount; i++) {
253
+ const result = decodeResource(buf, offset);
254
+ if (!result) break;
255
+ const [record, newOffset] = result;
256
+ answers.push(record);
257
+ offset = newOffset;
258
+ }
259
+ for (let i = 0; i < header.nscount; i++) {
260
+ const result = decodeResource(buf, offset);
261
+ if (!result) break;
262
+ offset = result[1];
263
+ }
264
+ const resources = [];
265
+ for (let i = 0; i < header.arcount; i++) {
266
+ const result = decodeResource(buf, offset);
267
+ if (!result) break;
268
+ const [record, newOffset] = result;
269
+ resources.push(record);
270
+ offset = newOffset;
271
+ }
272
+ return {
273
+ header,
274
+ answers,
275
+ resources
276
+ };
277
+ }
278
+ /**
279
+ * Collects the records of several mDNS responses and joins their PTR, SRV, TXT and A records
280
+ * into {@link MdnsService} values.
281
+ */
282
+ var ServiceCollector = class {
283
+ /** The instance names per service type, from PTR records. */
284
+ #ptrMap = /* @__PURE__ */ new Map();
285
+ /** The SRV record per instance name. */
286
+ #srvMap = /* @__PURE__ */ new Map();
287
+ /** The TXT properties per instance name. */
288
+ #txtMap = /* @__PURE__ */ new Map();
289
+ /** The IPv4 address per hostname, from A records. */
290
+ #addressMap = /* @__PURE__ */ new Map();
291
+ /**
292
+ * Sorts answer and additional records into the maps by record type.
293
+ */
294
+ addRecords(answers, resources) {
295
+ for (const record of [...answers, ...resources]) switch (record.qtype) {
296
+ case QueryType.PTR: {
297
+ const existing = this.#ptrMap.get(record.qname);
298
+ if (existing) existing.add(record.rdata);
299
+ else this.#ptrMap.set(record.qname, new Set([record.rdata]));
300
+ break;
301
+ }
302
+ case QueryType.SRV:
303
+ this.#srvMap.set(record.qname, record.rdata);
304
+ break;
305
+ case QueryType.TXT:
306
+ this.#txtMap.set(record.qname, record.rdata);
307
+ break;
308
+ case QueryType.A:
309
+ this.#addressMap.set(record.qname, record.rdata);
310
+ break;
311
+ }
312
+ }
313
+ /**
314
+ * The services that have a PTR record, an SRV record with a nonzero port and an A record
315
+ * for the SRV target.
316
+ */
317
+ get services() {
318
+ const results = [];
319
+ for (const [serviceType, instanceNames] of this.#ptrMap) for (const instanceQName of instanceNames) {
320
+ const srv = this.#srvMap.get(instanceQName);
321
+ if (!srv || srv.port === 0) continue;
322
+ const address = this.#addressMap.get(srv.target);
323
+ if (!address) continue;
324
+ const txt = this.#txtMap.get(instanceQName) ?? {};
325
+ const typeIndex = instanceQName.indexOf("._");
326
+ const name = typeIndex >= 0 ? instanceQName.substring(0, typeIndex) : instanceQName;
327
+ if (!results.some((result) => result.name === name && result.type === serviceType)) results.push({
328
+ name,
329
+ type: serviceType,
330
+ address,
331
+ port: srv.port,
332
+ properties: txt
333
+ });
334
+ }
335
+ return results;
336
+ }
337
+ };
338
+ /**
339
+ * Opens a TCP connection to each port to wake a sleeping device before a query. The caller
340
+ * knows which ports wake it. Resolves once every attempt settled.
341
+ */
342
+ function knock(address, ports) {
343
+ const promises = ports.map((port) => new Promise((resolve) => {
344
+ const socket = createConnection({
345
+ host: address,
346
+ port,
347
+ timeout: 500
348
+ });
349
+ socket.on("connect", () => {
350
+ socket.destroy();
351
+ resolve();
352
+ });
353
+ socket.on("error", () => {
354
+ socket.destroy();
355
+ resolve();
356
+ });
357
+ socket.on("timeout", () => {
358
+ socket.destroy();
359
+ resolve();
360
+ });
361
+ }));
362
+ return Promise.all(promises).then(() => {});
363
+ }
364
+ /**
365
+ * Queries hosts with unicast DNS-SD. It knocks on the `wakePorts` of each host first, then
366
+ * sends the queries to port 5353 of each host every second. It stops when `timeout` ends or
367
+ * every host answered for every service.
368
+ *
369
+ * @param hosts - IP addresses of the hosts to query.
370
+ * @param timeout - Duration in seconds.
371
+ */
372
+ function mdnsUnicast(hosts, services, timeout = 4, wakePorts = []) {
373
+ const expectedCount = hosts.length * services.length;
374
+ return new Promise((resolve) => {
375
+ const queries = createQueryPackets(services, QueryType.PTR, true);
376
+ const collector = new ServiceCollector();
377
+ const socket = createSocket("udp4");
378
+ let resolved = false;
379
+ const finish = () => {
380
+ if (resolved) return;
381
+ resolved = true;
382
+ clearInterval(interval);
383
+ try {
384
+ socket.close();
385
+ } catch {}
386
+ resolve(collector.services.filter((service) => services.includes(service.type)));
387
+ };
388
+ socket.on("message", (data) => {
389
+ try {
390
+ const response = decodeDnsResponse(data);
391
+ collector.addRecords(response.answers, response.resources);
392
+ if (collector.services.length >= expectedCount) finish();
393
+ } catch {}
394
+ });
395
+ socket.on("error", () => {});
396
+ for (const host of hosts) knock(host, wakePorts);
397
+ const sendQueries = () => {
398
+ for (const host of hosts) for (const query of queries) socket.send(query, MDNS_PORT$1, host);
399
+ };
400
+ let interval;
401
+ sendQueries();
402
+ interval = setInterval(sendQueries, 1e3);
403
+ setTimeout(finish, timeout * 1e3);
404
+ });
405
+ }
406
+ /**
407
+ * Discovers services with multicast DNS-SD on every local interface. It binds port 5353 when
408
+ * it can, plus one socket on a random port per interface.
409
+ *
410
+ * @param timeout - Duration in seconds.
411
+ */
412
+ function mdnsMulticast(services, timeout = 4) {
413
+ return new Promise((resolve) => {
414
+ const collector = new ServiceCollector();
415
+ const queries = createQueryPackets(services);
416
+ const sockets = [];
417
+ let resolved = false;
418
+ let interval;
419
+ const finish = () => {
420
+ if (resolved) return;
421
+ resolved = true;
422
+ clearInterval(interval);
423
+ for (const socket of sockets) try {
424
+ socket.close();
425
+ } catch {}
426
+ resolve(collector.services.filter((service) => services.includes(service.type)));
427
+ };
428
+ const onMessage = (data) => {
429
+ try {
430
+ const response = decodeDnsResponse(data);
431
+ collector.addRecords(response.answers, response.resources);
432
+ } catch {}
433
+ };
434
+ const addSocket = (address, port) => {
435
+ return new Promise((resolveSocket) => {
436
+ const socket = createSocket({
437
+ type: "udp4",
438
+ reuseAddr: true
439
+ });
440
+ socket.on("message", onMessage);
441
+ socket.on("error", () => {
442
+ resolveSocket(null);
443
+ });
444
+ socket.bind(port, address ?? "", () => {
445
+ if (address) try {
446
+ socket.setMulticastInterface(address);
447
+ socket.addMembership(MDNS_ADDRESS$1, address);
448
+ } catch {}
449
+ else try {
450
+ socket.addMembership(MDNS_ADDRESS$1);
451
+ } catch {}
452
+ sockets.push(socket);
453
+ resolveSocket(socket);
454
+ });
455
+ });
456
+ };
457
+ const getPrivateAddresses = () => {
458
+ try {
459
+ const { networkInterfaces } = __require("node:os");
460
+ const interfaces = networkInterfaces();
461
+ const addresses = [];
462
+ for (const nets of Object.values(interfaces)) for (const net of nets) if (net.family === "IPv4" && net.internal === false) addresses.push(net.address);
463
+ return addresses;
464
+ } catch {
465
+ return [];
466
+ }
467
+ };
468
+ const setup = async () => {
469
+ await addSocket(null, MDNS_PORT$1);
470
+ for (const address of getPrivateAddresses()) await addSocket(address, 0);
471
+ if (sockets.length === 0) {
472
+ resolve([]);
473
+ return;
474
+ }
475
+ const sendQueries = () => {
476
+ for (const socket of sockets) for (const query of queries) try {
477
+ socket.send(query, MDNS_PORT$1, MDNS_ADDRESS$1);
478
+ } catch {}
479
+ };
480
+ sendQueries();
481
+ interval = setInterval(sendQueries, 1e3);
482
+ setTimeout(finish, timeout * 1e3);
483
+ };
484
+ setup();
485
+ });
486
+ }
487
+
488
+ //#endregion
489
+ //#region src/discovery/discovery.ts
490
+ const CACHE_TTL = 3e4;
491
+ const logger = new Logger("discovery");
492
+ /**
493
+ * Finds the services of one mDNS service type. Each protocol package knows its own service
494
+ * type and what its TXT record means.
495
+ *
496
+ * Results stay cached for 30 seconds per service type, and concurrent scans for the same types
497
+ * share one multicast query. All instances share the cache on purpose, so protocol packages and
498
+ * `@corewire/devices` do not each flood the network with their own query.
499
+ */
500
+ var Discovery = class Discovery {
501
+ static #cache = /* @__PURE__ */ new Map();
502
+ static #scans = /* @__PURE__ */ new Map();
503
+ get service() {
504
+ return this.#service;
505
+ }
506
+ #service;
507
+ /** @param service - The mDNS service type, such as `_hap._tcp.local`. */
508
+ constructor(service) {
509
+ this.#service = service;
510
+ }
511
+ async find(useCache = true) {
512
+ const now = performance.now();
513
+ for (const [key, entry] of Discovery.#cache) if (entry.expiresAt <= now) Discovery.#cache.delete(key);
514
+ return (await Discovery.findServices([this.#service], useCache)).get(this.#service);
515
+ }
516
+ /**
517
+ * Scans again until a device with this id answers. Skips the cache on every attempt.
518
+ *
519
+ * @param timeout - Delay in milliseconds between attempts.
520
+ * @throws {DiscoveryError} If the device does not answer within `tries` attempts.
521
+ */
522
+ async findUntil(id, tries = 10, timeout = 1e3) {
523
+ while (tries > 0) {
524
+ const devices = await this.find(false);
525
+ const device = devices.find((device) => device.id === id);
526
+ if (device) return device;
527
+ logger.debug(`Device '${id}' not found, retrying in ${timeout}ms...`, devices.map((device) => device.id));
528
+ tries--;
529
+ await waitFor(timeout);
530
+ }
531
+ throw new DiscoveryError(`Device '${id}' not found after several tries, aborting.`);
532
+ }
533
+ /**
534
+ * Queries one host with unicast DNS-SD, bypassing the cache.
535
+ *
536
+ * @param timeout - Discovery duration in seconds.
537
+ * @param wakePorts - Ports to knock on first, to wake a sleeping device.
538
+ */
539
+ async findByAddress(address, timeout = 4, wakePorts = []) {
540
+ const match = (await mdnsUnicast([address], [this.#service], timeout, wakePorts)).find((service) => service.address === address);
541
+ return match ? toDiscoveryResult(match) : null;
542
+ }
543
+ /** Scans for several service types with one multicast query, shared with a scan in flight for the same types. */
544
+ static async findServices(services, useCache = true) {
545
+ const result = /* @__PURE__ */ new Map();
546
+ const missing = [...new Set(services)].filter((service) => {
547
+ const cached = Discovery.#cache.get(service);
548
+ if (useCache && cached && cached.expiresAt > performance.now()) {
549
+ result.set(service, cached.results);
550
+ return false;
551
+ }
552
+ return true;
553
+ }).sort();
554
+ if (missing.length === 0) return result;
555
+ const key = missing.join(",");
556
+ let scan = Discovery.#scans.get(key);
557
+ if (!scan) {
558
+ scan = mdnsMulticast(missing, 4).then((records) => {
559
+ const found = /* @__PURE__ */ new Map();
560
+ for (const service of missing) {
561
+ const results = records.filter((record) => record.type === service || `${record.type}.local` === service).map(toDiscoveryResult);
562
+ found.set(service, results);
563
+ Discovery.#cache.set(service, {
564
+ results,
565
+ expiresAt: performance.now() + CACHE_TTL
566
+ });
567
+ }
568
+ return found;
569
+ }).finally(() => Discovery.#scans.delete(key));
570
+ Discovery.#scans.set(key, scan);
571
+ }
572
+ for (const [service, records] of await scan) result.set(service, records);
573
+ return result;
574
+ }
575
+ static clearCache() {
576
+ Discovery.#cache.clear();
577
+ }
578
+ /** Knocks on each port to wake a sleeping device. */
579
+ static wake(address, ports) {
580
+ return knock(address, ports);
581
+ }
582
+ };
583
+ function toDiscoveryResult(service) {
584
+ const txt = service.properties;
585
+ const hostname = service.name.replace(/\s+/g, "-");
586
+ return {
587
+ id: `${hostname}.local`,
588
+ fqdn: `${hostname}.local`,
589
+ address: service.address,
590
+ txt,
591
+ service: {
592
+ port: service.port,
593
+ protocol: service.type.includes("._tcp") ? "tcp" : "udp",
594
+ type: service.type
595
+ }
596
+ };
597
+ }
598
+
599
+ //#endregion
600
+ //#region src/discovery/responder.ts
601
+ /** The IPv4 multicast address of mDNS, from RFC 6762. */
602
+ const MDNS_ADDRESS = "224.0.0.251";
603
+ const MDNS_PORT = 5353;
604
+ /** The DNS record types that the responder writes. */
605
+ const Type = {
606
+ A: 1,
607
+ PTR: 12,
608
+ TXT: 16,
609
+ SRV: 33
610
+ };
611
+ /** Class IN with the cache-flush bit set, used for records unique to this host. */
612
+ const CLASS_FLUSH = 32769;
613
+ /** Class IN, used for the shared PTR record. */
614
+ const CLASS_IN = 1;
615
+ /** The TTL of every record in seconds. */
616
+ const TTL = 120;
617
+ /**
618
+ * Advertises one service with PTR, SRV, TXT and A records every 2 seconds, and again on each
619
+ * query that mentions its type. It does not parse DNS questions, so it suits quiet test networks.
620
+ * A controller can need the instance name and TXT record of a real device before it lists the service.
621
+ */
622
+ var MdnsResponder = class MdnsResponder {
623
+ #logger;
624
+ #service;
625
+ #address;
626
+ #socket;
627
+ #timer;
628
+ /**
629
+ * @param address - The local IPv4 address that the A record publishes.
630
+ */
631
+ constructor(logger, service, address = MdnsResponder.localAddress()) {
632
+ this.#logger = logger;
633
+ this.#service = service;
634
+ this.#address = address;
635
+ }
636
+ /** Returns the first non-internal IPv4 address of this host, or `0.0.0.0` if none is found. */
637
+ static localAddress() {
638
+ for (const nets of Object.values(networkInterfaces())) for (const net of nets ?? []) if (net.family === "IPv4" && !net.internal) return net.address;
639
+ return "0.0.0.0";
640
+ }
641
+ /** Binds the multicast socket and announces the service every 2 seconds. */
642
+ start() {
643
+ const socket = createSocket({
644
+ type: "udp4",
645
+ reuseAddr: true
646
+ });
647
+ socket.on("error", (error) => {
648
+ this.#logger.warn("[mdns]", "mDNS responder socket error", error.message);
649
+ });
650
+ socket.on("message", (data) => {
651
+ if ((data.readUInt16BE(2) & 32768) === 0 && data.includes(Buffer.from(this.#service.type.split(".")[0]))) this.#announce();
652
+ });
653
+ socket.bind(MDNS_PORT, () => {
654
+ try {
655
+ socket.addMembership(MDNS_ADDRESS);
656
+ socket.setMulticastTTL(255);
657
+ } catch {}
658
+ this.#logger.info("[mdns]", `Advertising ${this.#service.instance} ${this.#service.type} on ${this.#address}:${this.#service.port}`);
659
+ this.#announce();
660
+ });
661
+ this.#socket = socket;
662
+ this.#timer = setInterval(() => this.#announce(), 2e3);
663
+ }
664
+ /** Stops announcing and closes the socket. */
665
+ stop() {
666
+ if (this.#timer) {
667
+ clearInterval(this.#timer);
668
+ this.#timer = void 0;
669
+ }
670
+ try {
671
+ this.#socket?.close();
672
+ } catch {}
673
+ this.#socket = void 0;
674
+ }
675
+ /** Multicasts the full record set. */
676
+ #announce() {
677
+ if (!this.#socket) return;
678
+ const message = this.#buildResponse();
679
+ try {
680
+ this.#socket.send(message, MDNS_PORT, MDNS_ADDRESS);
681
+ } catch (error) {
682
+ this.#logger.warn("[mdns]", "mDNS announce failed", error.message);
683
+ }
684
+ }
685
+ /** Encodes the PTR, SRV, TXT and A records as one mDNS response. */
686
+ #buildResponse() {
687
+ const instanceFqdn = `${this.#service.instance}.${this.#service.type}`;
688
+ const header = Buffer.allocUnsafe(12);
689
+ header.writeUInt16BE(0, 0);
690
+ header.writeUInt16BE(33792, 2);
691
+ header.writeUInt16BE(0, 4);
692
+ header.writeUInt16BE(4, 6);
693
+ header.writeUInt16BE(0, 8);
694
+ header.writeUInt16BE(0, 10);
695
+ const ptr = record(this.#service.type, Type.PTR, CLASS_IN, encodeName(instanceFqdn));
696
+ const srv = record(instanceFqdn, Type.SRV, CLASS_FLUSH, encodeSrv(this.#service.port, this.#service.host));
697
+ const txt = record(instanceFqdn, Type.TXT, CLASS_FLUSH, encodeTxt(this.#service.txt));
698
+ const address = record(this.#service.host, Type.A, CLASS_FLUSH, encodeAddress(this.#address));
699
+ return Buffer.concat([
700
+ header,
701
+ ptr,
702
+ srv,
703
+ txt,
704
+ address
705
+ ]);
706
+ }
707
+ };
708
+ /** Encodes a domain name as length-prefixed DNS labels and a closing zero byte. A label keeps at most 63 bytes. */
709
+ function encodeName(name) {
710
+ const parts = [];
711
+ for (const label of name.split(".")) {
712
+ if (label.length === 0) continue;
713
+ const encoded = Buffer.from(label, "utf8").subarray(0, 63);
714
+ parts.push(Buffer.from([encoded.byteLength]), encoded);
715
+ }
716
+ parts.push(Buffer.from([0]));
717
+ return Buffer.concat(parts);
718
+ }
719
+ /** Wraps rdata in a resource record: name, type, class, TTL, rdata length and rdata. */
720
+ function record(name, type, recordClass, rdata) {
721
+ const head = encodeName(name);
722
+ const meta = Buffer.allocUnsafe(10);
723
+ meta.writeUInt16BE(type, 0);
724
+ meta.writeUInt16BE(recordClass, 2);
725
+ meta.writeUInt32BE(TTL, 4);
726
+ meta.writeUInt16BE(rdata.byteLength, 8);
727
+ return Buffer.concat([
728
+ head,
729
+ meta,
730
+ rdata
731
+ ]);
732
+ }
733
+ /** Encodes SRV rdata: priority, weight, port and the target host name. */
734
+ function encodeSrv(port, host) {
735
+ const head = Buffer.allocUnsafe(6);
736
+ head.writeUInt16BE(0, 0);
737
+ head.writeUInt16BE(0, 2);
738
+ head.writeUInt16BE(port, 4);
739
+ return Buffer.concat([head, encodeName(host)]);
740
+ }
741
+ /** Encodes TXT rdata as a sequence of length-prefixed `key=value` strings. */
742
+ function encodeTxt(txt) {
743
+ const parts = [];
744
+ for (const [key, value] of Object.entries(txt)) {
745
+ const entry = Buffer.from(value === "" ? key : `${key}=${value}`, "utf8").subarray(0, 255);
746
+ parts.push(Buffer.from([entry.byteLength]), entry);
747
+ }
748
+ if (parts.length === 0) parts.push(Buffer.from([0]));
749
+ return Buffer.concat(parts);
750
+ }
751
+ /** Encodes an IPv4 dotted-quad address as 4 bytes. */
752
+ function encodeAddress(address) {
753
+ const octets = address.split(".").map((part) => Number.parseInt(part, 10) & 255);
754
+ return Buffer.from(octets.length === 4 ? octets : [
755
+ 0,
756
+ 0,
757
+ 0,
758
+ 0
759
+ ]);
760
+ }
761
+
762
+ //#endregion
763
+ export { Discovery, MdnsResponder, mdnsMulticast, mdnsUnicast };