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,17 @@
1
+ /** mDNS multicast IPv4 address (RFC 6762 §3) */
2
+ export const MDNS_ADDRESS = '224.0.0.251'
3
+
4
+ /** mDNS multicast IPv6 address (RFC 6762 §3) */
5
+ export const MDNS_ADDRESS_V6 = 'FF02::FB'
6
+
7
+ /** mDNS default port (RFC 6762 §3) */
8
+ export const MDNS_PORT = 5353
9
+
10
+ /** mDNS multicast TTL — must be 255 per RFC 6762 §11 */
11
+ export const MDNS_TTL = 255
12
+
13
+ /** Meta-query name for service type enumeration (RFC 6763 §9) */
14
+ export const SERVICE_TYPE_ENUMERATION = '_services._dns-sd._udp.local'
15
+
16
+ /** Default domain for mDNS */
17
+ export const DEFAULT_DOMAIN = 'local'
package/lib/dns.js ADDED
@@ -0,0 +1,685 @@
1
+ /**
2
+ * DNS packet encoder/decoder for mDNS.
3
+ *
4
+ * Implements the subset of DNS wire format (RFC 1035) needed for DNS-SD
5
+ * browsing over mDNS (RFC 6762, RFC 6763). Handles DNS name compression,
6
+ * the mDNS cache-flush bit, and all record types used by DNS-SD:
7
+ * PTR, SRV, TXT, A, AAAA.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ /** @enum {number} */
13
+ export const RecordType = /** @type {const} */ ({
14
+ A: 1,
15
+ PTR: 12,
16
+ TXT: 16,
17
+ AAAA: 28,
18
+ SRV: 33,
19
+ ANY: 255,
20
+ });
21
+
22
+ /** DNS class IN */
23
+ const CLASS_IN = 1;
24
+
25
+ /** mDNS cache-flush bit — high bit of the class field (RFC 6762 §10.2) */
26
+ const CACHE_FLUSH_BIT = 0x8000;
27
+
28
+ /** DNS name pointer mask — two highest bits set (RFC 1035 §4.1.4) */
29
+ const POINTER_MASK = 0xc0;
30
+
31
+ /** Maximum DNS name label length */
32
+ const MAX_LABEL_LENGTH = 63;
33
+
34
+ /** Maximum total DNS name length (RFC 1035 §2.3.4) */
35
+ const MAX_NAME_LENGTH = 253;
36
+
37
+ /** Minimum DNS packet size: 12-byte header */
38
+ const MIN_PACKET_SIZE = 12;
39
+
40
+ /**
41
+ * Maximum number of resource records we'll parse from a single packet.
42
+ * Prevents CPU exhaustion from crafted packets with huge counts in the header.
43
+ * Normal mDNS responses rarely exceed ~30 records.
44
+ */
45
+ const MAX_RECORDS_PER_PACKET = 256;
46
+
47
+ /** Shared TextDecoder instance for DNS label decoding */
48
+ const textDecoder = new TextDecoder();
49
+
50
+ /**
51
+ * @typedef {object} DnsFlags
52
+ * @property {boolean} qr - true for response, false for query
53
+ * @property {number} opcode
54
+ * @property {boolean} aa - Authoritative Answer
55
+ * @property {boolean} tc - Truncated
56
+ * @property {boolean} rd - Recursion Desired
57
+ * @property {boolean} ra - Recursion Available
58
+ * @property {number} rcode - Response code
59
+ */
60
+
61
+ /**
62
+ * @typedef {object} DnsQuestion
63
+ * @property {string} name
64
+ * @property {number} type - RecordType value
65
+ * @property {boolean} [qu] - QU (unicast-response) bit (RFC 6762 §5.4)
66
+ */
67
+
68
+ /**
69
+ * @typedef {object} SrvData
70
+ * @property {number} priority
71
+ * @property {number} weight
72
+ * @property {number} port
73
+ * @property {string} target
74
+ */
75
+
76
+ /**
77
+ * @typedef {object} DnsRecord
78
+ * @property {string} name
79
+ * @property {number} type - RecordType value
80
+ * @property {number} class - Usually 1 (IN)
81
+ * @property {boolean} cacheFlush - mDNS cache-flush bit
82
+ * @property {number} ttl
83
+ * @property {string | SrvData | Uint8Array[]} data - Type-specific data
84
+ */
85
+
86
+ /**
87
+ * @typedef {object} DnsPacket
88
+ * @property {number} id
89
+ * @property {DnsFlags} flags
90
+ * @property {DnsQuestion[]} questions
91
+ * @property {DnsRecord[]} answers
92
+ * @property {DnsRecord[]} authorities
93
+ * @property {DnsRecord[]} additionals
94
+ */
95
+
96
+ /**
97
+ * Decode a DNS packet from a buffer.
98
+ * @param {Uint8Array} buf
99
+ * @returns {DnsPacket}
100
+ */
101
+ export function decode(buf) {
102
+ if (buf.byteLength < MIN_PACKET_SIZE) {
103
+ throw new Error(
104
+ `DNS packet too short: ${buf.byteLength} bytes (minimum ${MIN_PACKET_SIZE})`,
105
+ );
106
+ }
107
+
108
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
109
+ let offset = 0;
110
+
111
+ // Header (RFC 1035 §4.1.1) — 12 bytes
112
+ const id = view.getUint16(offset);
113
+ offset += 2;
114
+
115
+ const flagBits = view.getUint16(offset);
116
+ offset += 2;
117
+
118
+ const flags = {
119
+ qr: (flagBits & 0x8000) !== 0,
120
+ opcode: (flagBits >> 11) & 0xf,
121
+ aa: (flagBits & 0x0400) !== 0,
122
+ tc: (flagBits & 0x0200) !== 0,
123
+ rd: (flagBits & 0x0100) !== 0,
124
+ ra: (flagBits & 0x0080) !== 0,
125
+ rcode: flagBits & 0x000f,
126
+ };
127
+
128
+ const qdcount = view.getUint16(offset);
129
+ offset += 2;
130
+ const ancount = view.getUint16(offset);
131
+ offset += 2;
132
+ const nscount = view.getUint16(offset);
133
+ offset += 2;
134
+ const arcount = view.getUint16(offset);
135
+ offset += 2;
136
+
137
+ // Cap record counts to prevent CPU exhaustion from crafted headers
138
+ const totalRecords = qdcount + ancount + nscount + arcount;
139
+ if (totalRecords > MAX_RECORDS_PER_PACKET) {
140
+ throw new Error(
141
+ `DNS packet claims ${totalRecords} records (maximum ${MAX_RECORDS_PER_PACKET})`,
142
+ );
143
+ }
144
+
145
+ const questions = [];
146
+ for (let i = 0; i < qdcount; i++) {
147
+ const { name, offset: newOffset } = decodeName(buf, offset);
148
+ offset = newOffset;
149
+ const qtype = view.getUint16(offset);
150
+ offset += 2;
151
+ const qclassBits = view.getUint16(offset);
152
+ offset += 2;
153
+ const qu = (qclassBits & CACHE_FLUSH_BIT) !== 0;
154
+ questions.push({ name, type: qtype, qu });
155
+ }
156
+
157
+ const answers = decodeRecords(buf, view, offset, ancount);
158
+ offset = answers.newOffset;
159
+ const authorities = decodeRecords(buf, view, offset, nscount);
160
+ offset = authorities.newOffset;
161
+ const additionals = decodeRecords(buf, view, offset, arcount);
162
+
163
+ return {
164
+ id,
165
+ flags,
166
+ questions,
167
+ answers: answers.records,
168
+ authorities: authorities.records,
169
+ additionals: additionals.records,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Decode N resource records starting at the given offset.
175
+ * @param {Uint8Array} buf
176
+ * @param {DataView} view
177
+ * @param {number} offset
178
+ * @param {number} count
179
+ * @returns {{ records: DnsRecord[], newOffset: number }}
180
+ */
181
+ function decodeRecords(buf, view, offset, count) {
182
+ const records = [];
183
+ for (let i = 0; i < count; i++) {
184
+ const { name, offset: nameEnd } = decodeName(buf, offset);
185
+ offset = nameEnd;
186
+
187
+ // Need at least 10 bytes for type(2) + class(2) + ttl(4) + rdlength(2)
188
+ if (offset + 10 > buf.byteLength) {
189
+ throw new Error(
190
+ "DNS record truncated: not enough bytes for record metadata",
191
+ );
192
+ }
193
+
194
+ const type = view.getUint16(offset);
195
+ offset += 2;
196
+ const classBits = view.getUint16(offset);
197
+ offset += 2;
198
+ const cacheFlush = (classBits & CACHE_FLUSH_BIT) !== 0;
199
+ const rrClass = classBits & ~CACHE_FLUSH_BIT;
200
+
201
+ const ttl = view.getUint32(offset);
202
+ offset += 4;
203
+ const rdlength = view.getUint16(offset);
204
+ offset += 2;
205
+
206
+ // Validate RDATA fits within the packet
207
+ if (offset + rdlength > buf.byteLength) {
208
+ throw new Error(
209
+ `DNS record RDATA overflows packet: offset=${offset} rdlength=${rdlength} bufLen=${buf.byteLength}`,
210
+ );
211
+ }
212
+
213
+ const data = decodeRecordData(buf, view, offset, type, rdlength);
214
+ offset += rdlength;
215
+
216
+ records.push({ name, type, class: rrClass, cacheFlush, ttl, data });
217
+ }
218
+ return { records, newOffset: offset };
219
+ }
220
+
221
+ /**
222
+ * Decode record-type-specific data (RDATA).
223
+ * @param {Uint8Array} buf
224
+ * @param {DataView} view
225
+ * @param {number} offset - Start of RDATA
226
+ * @param {number} type - Record type
227
+ * @param {number} rdlength - Length of RDATA
228
+ * @returns {string | SrvData | Uint8Array[]}
229
+ */
230
+ function decodeRecordData(buf, view, offset, type, rdlength) {
231
+ switch (type) {
232
+ case RecordType.A: {
233
+ if (rdlength !== 4) return "";
234
+ return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`;
235
+ }
236
+
237
+ case RecordType.AAAA: {
238
+ if (rdlength !== 16) return "";
239
+ const parts = [];
240
+ for (let i = 0; i < 16; i += 2) {
241
+ parts.push(view.getUint16(offset + i).toString(16));
242
+ }
243
+ return compressIPv6(parts.join(":"));
244
+ }
245
+
246
+ case RecordType.PTR: {
247
+ const { name } = decodeName(buf, offset);
248
+ return name;
249
+ }
250
+
251
+ case RecordType.SRV: {
252
+ // SRV RDATA: priority(2) + weight(2) + port(2) + target (min 1 byte for root)
253
+ if (rdlength < 7) {
254
+ throw new Error(
255
+ `SRV record RDATA too short: ${rdlength} bytes (minimum 7)`,
256
+ );
257
+ }
258
+ const priority = view.getUint16(offset);
259
+ const weight = view.getUint16(offset + 2);
260
+ const port = view.getUint16(offset + 4);
261
+ const { name: target } = decodeName(buf, offset + 6);
262
+ return { priority, weight, port, target };
263
+ }
264
+
265
+ case RecordType.TXT: {
266
+ return decodeTxtData(buf, offset, rdlength);
267
+ }
268
+
269
+ default: {
270
+ // Return raw bytes for unknown record types
271
+ return [buf.slice(offset, offset + rdlength)];
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Decode TXT record data into an array of Uint8Array strings.
278
+ * Each string is length-prefixed (RFC 1035 §3.3.14).
279
+ * @param {Uint8Array} buf
280
+ * @param {number} offset
281
+ * @param {number} rdlength
282
+ * @returns {Uint8Array[]}
283
+ */
284
+ function decodeTxtData(buf, offset, rdlength) {
285
+ const strings = [];
286
+ const end = offset + rdlength;
287
+ while (offset < end) {
288
+ const len = buf[offset];
289
+ offset += 1;
290
+ if (len === 0) {
291
+ // Empty string — represents an empty TXT record (RFC 6763 §6.1)
292
+ continue;
293
+ }
294
+ // Validate TXT string length stays within RDATA boundary
295
+ if (offset + len > end) {
296
+ throw new Error("TXT record string length exceeds RDATA boundary");
297
+ }
298
+ strings.push(buf.slice(offset, offset + len));
299
+ offset += len;
300
+ }
301
+ return strings;
302
+ }
303
+
304
+ /**
305
+ * Decode a DNS name from the buffer, handling compression pointers.
306
+ * @param {Uint8Array} buf
307
+ * @param {number} offset
308
+ * @returns {{ name: string, offset: number }}
309
+ */
310
+ function decodeName(buf, offset) {
311
+ const labels = [];
312
+ let jumped = false;
313
+ let returnOffset = offset;
314
+ let totalLength = 0;
315
+
316
+ // Guard against infinite loops from malicious pointer chains
317
+ let maxJumps = 128;
318
+ while (maxJumps-- > 0) {
319
+ if (offset >= buf.length) {
320
+ throw new Error("DNS name decode: offset beyond buffer");
321
+ }
322
+
323
+ const len = buf[offset];
324
+
325
+ if (len === 0) {
326
+ // End of name
327
+ if (!jumped) returnOffset = offset + 1;
328
+ break;
329
+ }
330
+
331
+ if ((len & POINTER_MASK) === POINTER_MASK) {
332
+ // Compressed name pointer (RFC 1035 §4.1.4)
333
+ if (offset + 1 >= buf.length) {
334
+ throw new Error("DNS name decode: pointer truncated");
335
+ }
336
+ if (!jumped) returnOffset = offset + 2;
337
+ const pointerOffset = ((len & ~POINTER_MASK) << 8) | buf[offset + 1];
338
+ // Pointer must target a valid position within the packet
339
+ if (pointerOffset >= buf.length) {
340
+ throw new Error(
341
+ `DNS name decode: pointer offset ${pointerOffset} beyond buffer length ${buf.length}`,
342
+ );
343
+ }
344
+ offset = pointerOffset;
345
+ jumped = true;
346
+ continue;
347
+ }
348
+
349
+ // Regular label
350
+ if (len > MAX_LABEL_LENGTH) {
351
+ throw new Error(
352
+ `DNS name decode: label length ${len} exceeds maximum ${MAX_LABEL_LENGTH}`,
353
+ );
354
+ }
355
+
356
+ offset += 1;
357
+
358
+ // Validate label data fits in buffer
359
+ if (offset + len > buf.length) {
360
+ throw new Error("DNS name decode: label extends beyond buffer");
361
+ }
362
+
363
+ const label = textDecoder.decode(buf.subarray(offset, offset + len));
364
+ const normalizedLabel = label.normalize("NFC");
365
+
366
+ // Enforce maximum total name length (RFC 1035 §2.3.4: 253 chars)
367
+ totalLength += len + (labels.length > 0 ? 1 : 0); // +1 for the dot separator
368
+ if (totalLength > MAX_NAME_LENGTH) {
369
+ throw new Error(
370
+ `DNS name exceeds maximum length of ${MAX_NAME_LENGTH} characters`,
371
+ );
372
+ }
373
+
374
+ labels.push(normalizedLabel);
375
+ offset += len;
376
+
377
+ // Track return position through each label when not following pointers
378
+ if (!jumped) returnOffset = offset;
379
+ }
380
+
381
+ if (maxJumps < 0) {
382
+ throw new Error("DNS name decode: too many compression pointers");
383
+ }
384
+
385
+ return { name: labels.join("."), offset: returnOffset };
386
+ }
387
+
388
+ /**
389
+ * Compress an IPv6 address string (collapse longest run of :0: groups).
390
+ * @param {string} addr - Fully expanded IPv6 (8 groups of hex)
391
+ * @returns {string}
392
+ */
393
+ function compressIPv6(addr) {
394
+ const parts = addr.split(":").map((p) => p.replace(/^0+/, "") || "0");
395
+ // Find longest run of consecutive '0' groups
396
+ let bestStart = -1;
397
+ let bestLen = 0;
398
+ let curStart = -1;
399
+ let curLen = 0;
400
+ for (let i = 0; i < parts.length; i++) {
401
+ if (parts[i] === "0") {
402
+ if (curStart === -1) curStart = i;
403
+ curLen++;
404
+ if (curLen > bestLen) {
405
+ bestStart = curStart;
406
+ bestLen = curLen;
407
+ }
408
+ } else {
409
+ curStart = -1;
410
+ curLen = 0;
411
+ }
412
+ }
413
+
414
+ if (bestLen < 2) return parts.join(":");
415
+
416
+ const before = parts.slice(0, bestStart).join(":");
417
+ const after = parts.slice(bestStart + bestLen).join(":");
418
+ return `${before}::${after}`;
419
+ }
420
+
421
+ // ─── Encoding ───────────────────────────────────────────────────────────
422
+
423
+ /** Maximum mDNS packet size before splitting (Ethernet MTU minus IP+UDP headers) */
424
+ const MAX_MDNS_PACKET_SIZE = 1472;
425
+
426
+ /**
427
+ * @typedef {object} QueryOptions
428
+ * @property {DnsQuestion[]} questions
429
+ * @property {DnsRecord[]} [answers] - Known answers for suppression (RFC 6762 §7.1)
430
+ */
431
+
432
+ /**
433
+ * Encode an mDNS query into one or more packets.
434
+ * If the known-answer list is too large for a single packet, splits across
435
+ * multiple packets per RFC 6762 §7.2: the first packet contains the question
436
+ * and sets the TC bit, continuation packets contain only answers (QDCOUNT=0).
437
+ * @param {QueryOptions} options
438
+ * @returns {Buffer[]} - Array of encoded packets (usually 1)
439
+ */
440
+ export function encodeQueryPackets({ questions, answers = [] }) {
441
+ // Try encoding everything in one packet first
442
+ const single = encodeQuery({ questions, answers });
443
+ if (single.length <= MAX_MDNS_PACKET_SIZE || answers.length === 0) {
444
+ return [single];
445
+ }
446
+
447
+ // Too large — split known answers across multiple packets.
448
+ // First packet: question + as many answers as fit, with TC bit set.
449
+ const packets = [];
450
+ let remaining = [...answers];
451
+
452
+ // Encode question-only packet to measure base size
453
+ const questionOnly = encodeQuery({ questions, answers: [] });
454
+ const baseSize = questionOnly.length;
455
+
456
+ // Greedily add answers to the first packet
457
+ let firstBatch = [];
458
+ let estimatedSize = baseSize;
459
+ while (remaining.length > 0) {
460
+ const trial = encodeQuery({ questions: [], answers: [remaining[0]] });
461
+ const recordSize = trial.length - 12; // subtract header
462
+ if (
463
+ estimatedSize + recordSize > MAX_MDNS_PACKET_SIZE &&
464
+ firstBatch.length > 0
465
+ )
466
+ break;
467
+ firstBatch.push(/** @type {DnsRecord} */ (remaining.shift()));
468
+ estimatedSize += recordSize;
469
+ }
470
+
471
+ // Encode first packet with TC bit set
472
+ const firstPkt = encodeQuery({ questions, answers: firstBatch });
473
+ // Set TC bit: byte 2, bit 1
474
+ firstPkt[2] = firstPkt[2] | 0x02;
475
+ packets.push(firstPkt);
476
+
477
+ // Continuation packets: answers only, no question, no TC
478
+ while (remaining.length > 0) {
479
+ let batch = [];
480
+ let size = 12; // header only
481
+ while (remaining.length > 0) {
482
+ const trial = encodeQuery({ questions: [], answers: [remaining[0]] });
483
+ const recordSize = trial.length - 12;
484
+ if (size + recordSize > MAX_MDNS_PACKET_SIZE && batch.length > 0) break;
485
+ batch.push(/** @type {DnsRecord} */ (remaining.shift()));
486
+ size += recordSize;
487
+ }
488
+ packets.push(encodeQuery({ questions: [], answers: batch }));
489
+ }
490
+
491
+ return packets;
492
+ }
493
+
494
+ /**
495
+ * Encode a single mDNS query packet.
496
+ * @param {QueryOptions} options
497
+ * @returns {Buffer}
498
+ */
499
+ export function encodeQuery({ questions, answers = [] }) {
500
+ /** @type {Map<string, number>} - Name compression table: name → offset */
501
+ const compressionTable = new Map();
502
+
503
+ // Header — 12 bytes
504
+ const header = Buffer.alloc(12);
505
+ // ID = 0 for mDNS queries (RFC 6762 §18.1)
506
+ header.writeUInt16BE(0, 0);
507
+ // Flags = 0 for standard query
508
+ header.writeUInt16BE(0, 2);
509
+ // QDCOUNT
510
+ header.writeUInt16BE(questions.length, 4);
511
+ // ANCOUNT (known answers for suppression)
512
+ header.writeUInt16BE(answers.length, 6);
513
+ // NSCOUNT
514
+ header.writeUInt16BE(0, 8);
515
+ // ARCOUNT
516
+ header.writeUInt16BE(0, 10);
517
+
518
+ /** @type {Buffer[]} */
519
+ const parts = [header];
520
+ let currentOffset = 12;
521
+
522
+ // Encode questions
523
+ for (const q of questions) {
524
+ const nameBytes = encodeName(q.name, compressionTable, currentOffset);
525
+ const qFooter = Buffer.alloc(4);
526
+ qFooter.writeUInt16BE(q.type, 0);
527
+ // QU bit in the class field for mDNS unicast-response
528
+ qFooter.writeUInt16BE(q.qu ? CLASS_IN | CACHE_FLUSH_BIT : CLASS_IN, 2);
529
+
530
+ parts.push(nameBytes, qFooter);
531
+ currentOffset += nameBytes.length + 4;
532
+ }
533
+
534
+ // Encode known answers
535
+ for (const record of answers) {
536
+ const recordBytes = encodeRecord(record, compressionTable, currentOffset);
537
+ parts.push(recordBytes);
538
+ currentOffset += recordBytes.length;
539
+ }
540
+
541
+ return Buffer.concat(parts);
542
+ }
543
+
544
+ /**
545
+ * Encode a single resource record.
546
+ * @param {DnsRecord} record
547
+ * @param {Map<string, number>} compressionTable
548
+ * @param {number} currentOffset
549
+ * @returns {Buffer}
550
+ */
551
+ function encodeRecord(record, compressionTable, currentOffset) {
552
+ const nameBytes = encodeName(record.name, compressionTable, currentOffset);
553
+ currentOffset += nameBytes.length;
554
+
555
+ const rdata = encodeRecordData(record, compressionTable, currentOffset + 10);
556
+
557
+ const meta = Buffer.alloc(10);
558
+ meta.writeUInt16BE(record.type, 0);
559
+ const classBits =
560
+ (record.class || CLASS_IN) | (record.cacheFlush ? CACHE_FLUSH_BIT : 0);
561
+ meta.writeUInt16BE(classBits, 2);
562
+ meta.writeUInt32BE(record.ttl, 4);
563
+ meta.writeUInt16BE(rdata.length, 8);
564
+
565
+ return Buffer.concat([nameBytes, meta, rdata]);
566
+ }
567
+
568
+ /**
569
+ * Encode record-type-specific data.
570
+ * @param {DnsRecord} record
571
+ * @param {Map<string, number>} compressionTable
572
+ * @param {number} rdataOffset
573
+ * @returns {Buffer}
574
+ */
575
+ function encodeRecordData(record, compressionTable, rdataOffset) {
576
+ switch (record.type) {
577
+ case RecordType.A: {
578
+ const data = /** @type {string} */ (record.data);
579
+ const parts = data.split(".").map(Number);
580
+ return Buffer.from(parts);
581
+ }
582
+
583
+ case RecordType.AAAA: {
584
+ const data = /** @type {string} */ (record.data);
585
+ return encodeIPv6(data);
586
+ }
587
+
588
+ case RecordType.PTR: {
589
+ const data = /** @type {string} */ (record.data);
590
+ return encodeName(data, compressionTable, rdataOffset);
591
+ }
592
+
593
+ case RecordType.SRV: {
594
+ const data = /** @type {SrvData} */ (record.data);
595
+ const header = Buffer.alloc(6);
596
+ header.writeUInt16BE(data.priority, 0);
597
+ header.writeUInt16BE(data.weight, 2);
598
+ header.writeUInt16BE(data.port, 4);
599
+ // SRV target MUST NOT use name compression (RFC 2782)
600
+ const targetBytes = encodeName(data.target, new Map(), rdataOffset + 6);
601
+ return Buffer.concat([header, targetBytes]);
602
+ }
603
+
604
+ case RecordType.TXT: {
605
+ const data = /** @type {Uint8Array[]} */ (record.data);
606
+ if (data.length === 0) return Buffer.from([0]);
607
+ const parts = data.map((s) => {
608
+ const len = Buffer.alloc(1);
609
+ len[0] = s.length;
610
+ return Buffer.concat([len, s]);
611
+ });
612
+ return Buffer.concat(parts);
613
+ }
614
+
615
+ /* c8 ignore next 2 -- defensive default for unknown record types */
616
+ default:
617
+ return Buffer.alloc(0);
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Encode a DNS name with compression.
623
+ * @param {string} name
624
+ * @param {Map<string, number>} compressionTable
625
+ * @param {number} currentOffset
626
+ * @returns {Buffer}
627
+ */
628
+ function encodeName(name, compressionTable, currentOffset) {
629
+ const labels = name.split(".");
630
+ const parts = [];
631
+
632
+ for (let i = 0; i < labels.length; i++) {
633
+ const suffix = labels.slice(i).join(".");
634
+
635
+ // Check if this suffix is already in the compression table
636
+ const pointer = compressionTable.get(suffix);
637
+ if (pointer !== undefined) {
638
+ const ptrBuf = Buffer.alloc(2);
639
+ ptrBuf.writeUInt16BE(0xc000 | pointer, 0);
640
+ parts.push(ptrBuf);
641
+ return Buffer.concat(parts);
642
+ }
643
+
644
+ // Store this suffix position for future compression
645
+ compressionTable.set(suffix, currentOffset);
646
+
647
+ let label = labels[i];
648
+ label = label.normalize("NFC");
649
+ const encoded = Buffer.from(label, "utf-8");
650
+ const lenBuf = Buffer.alloc(1);
651
+ lenBuf[0] = encoded.length;
652
+ parts.push(lenBuf, encoded);
653
+ currentOffset += 1 + encoded.length;
654
+ }
655
+
656
+ // Null terminator
657
+ parts.push(Buffer.from([0]));
658
+ return Buffer.concat(parts);
659
+ }
660
+
661
+ /**
662
+ * Encode an IPv6 address to a 16-byte buffer.
663
+ * @param {string} addr
664
+ * @returns {Buffer}
665
+ */
666
+ function encodeIPv6(addr) {
667
+ const buf = Buffer.alloc(16);
668
+ // Expand :: shorthand
669
+ let fullAddr = addr;
670
+ if (fullAddr.includes("::")) {
671
+ const [left, right] = fullAddr.split("::");
672
+ const leftParts = left ? left.split(":") : [];
673
+ const rightParts = right ? right.split(":") : [];
674
+ const missing = 8 - leftParts.length - rightParts.length;
675
+ const middle = Array(missing).fill("0");
676
+ fullAddr = [...leftParts, ...middle, ...rightParts].join(":");
677
+ }
678
+
679
+ const parts = fullAddr.split(":");
680
+ for (let i = 0; i < 8; i++) {
681
+ const val = parseInt(parts[i] || "0", 16);
682
+ buf.writeUInt16BE(val, i * 2);
683
+ }
684
+ return buf;
685
+ }