@nictool/dns-resource-record 1.6.1 → 1.7.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +323 -199
  3. package/dist/dns-rr.min.js +26 -0
  4. package/dist/dns-rr.min.js.map +7 -0
  5. package/index.js +12 -11
  6. package/lib/binary.js +108 -0
  7. package/lib/bind.js +94 -0
  8. package/lib/dns-query.js +67 -0
  9. package/lib/tinydns.js +128 -44
  10. package/lib/wire.js +519 -0
  11. package/package.json +23 -6
  12. package/rr/TEMPLATE.js +124 -0
  13. package/rr/a.js +8 -48
  14. package/rr/aaaa.js +13 -80
  15. package/rr/apl.js +123 -27
  16. package/rr/caa.js +27 -50
  17. package/rr/cert.js +58 -87
  18. package/rr/cname.js +7 -63
  19. package/rr/dhcid.js +20 -30
  20. package/rr/dname.js +9 -36
  21. package/rr/dnskey.js +38 -32
  22. package/rr/ds.js +43 -57
  23. package/rr/hinfo.js +21 -40
  24. package/rr/hip.js +88 -26
  25. package/rr/https.js +28 -40
  26. package/rr/ipseckey.js +97 -18
  27. package/rr/key.js +39 -29
  28. package/rr/kx.js +16 -27
  29. package/rr/loc.js +42 -12
  30. package/rr/mx.js +16 -51
  31. package/rr/naptr.js +58 -33
  32. package/rr/ns.js +8 -36
  33. package/rr/nsec.js +89 -18
  34. package/rr/nsec3.js +62 -26
  35. package/rr/nsec3param.js +56 -53
  36. package/rr/nxt.js +57 -18
  37. package/rr/openpgpkey.js +24 -30
  38. package/rr/ptr.js +7 -47
  39. package/rr/rp.js +17 -32
  40. package/rr/rrsig.js +80 -85
  41. package/rr/sig.js +70 -45
  42. package/rr/smimea.js +33 -33
  43. package/rr/soa.js +31 -49
  44. package/rr/spf.js +10 -17
  45. package/rr/srv.js +22 -62
  46. package/rr/sshfp.js +25 -53
  47. package/rr/svcb.js +27 -40
  48. package/rr/tlsa.js +34 -34
  49. package/rr/tsig.js +87 -24
  50. package/rr/txt.js +40 -59
  51. package/rr/uri.js +19 -29
  52. package/rr/wks.js +154 -22
  53. package/rr.js +290 -109
  54. package/lib/readme.js +0 -84
package/lib/wire.js ADDED
@@ -0,0 +1,519 @@
1
+ import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from './binary.js'
2
+ import { expandIPv6 } from './tinydns.js'
3
+
4
+ // ── Name encoding/decoding ────────────────────────────────────────────────────
5
+
6
+ export function encodeName(name) {
7
+ const labels = name.replace(/\.$/, '').split('.')
8
+ const parts = labels.map((l) => {
9
+ const b = Buffer.from(l)
10
+ return Buffer.concat([Buffer.from([b.length]), b])
11
+ })
12
+ return Buffer.concat([...parts, Buffer.from([0])])
13
+ }
14
+
15
+ /**
16
+ * Read a (possibly pointer-compressed) DNS name from a packet.
17
+ * Returns { bytes: Buffer (uncompressed wire name), end: offset after name }.
18
+ */
19
+ export function readWireName(packet, offset) {
20
+ const labels = []
21
+ let pos = offset
22
+ let end = -1
23
+
24
+ while (pos < packet.length) {
25
+ const len = packet[pos]
26
+ if (len === 0) {
27
+ if (end === -1) end = pos + 1
28
+ labels.push(Buffer.from([0]))
29
+ break
30
+ }
31
+ if ((len & 0xc0) === 0xc0) {
32
+ if (end === -1) end = pos + 2
33
+ pos = ((len & 0x3f) << 8) | packet[pos + 1]
34
+ continue
35
+ }
36
+ const label = packet.slice(pos, pos + 1 + len)
37
+ labels.push(label)
38
+ pos += 1 + len
39
+ }
40
+
41
+ return { bytes: Buffer.concat(labels), end }
42
+ }
43
+
44
+ /**
45
+ * Decompress rdata for types that embed domain names (RFC 1035 §3.3).
46
+ * For all other types, returns the raw rdata bytes unchanged.
47
+ */
48
+ export function decompressRdata(packet, rdataOffset, rdlen, typeId) {
49
+ const raw = packet.slice(rdataOffset, rdataOffset + rdlen)
50
+
51
+ switch (typeId) {
52
+ // Single name: NS, CNAME, PTR, DNAME
53
+ case 2:
54
+ case 5:
55
+ case 12:
56
+ case 39: {
57
+ const { bytes } = readWireName(packet, rdataOffset)
58
+ return bytes
59
+ }
60
+
61
+ // SOA: mname + rname + 5 × uint32
62
+ case 6: {
63
+ const { bytes: mname, end: e1 } = readWireName(packet, rdataOffset)
64
+ const { bytes: rname, end: e2 } = readWireName(packet, e1)
65
+ const tail = packet.slice(e2, rdataOffset + rdlen) // 20 bytes
66
+ return Buffer.concat([mname, rname, tail])
67
+ }
68
+
69
+ // preference(2) + name: MX, KX
70
+ case 15:
71
+ case 36: {
72
+ const pref = raw.slice(0, 2)
73
+ const { bytes } = readWireName(packet, rdataOffset + 2)
74
+ return Buffer.concat([pref, bytes])
75
+ }
76
+
77
+ // priority(2) + weight(2) + port(2) + name: SRV
78
+ case 33: {
79
+ const head = raw.slice(0, 6)
80
+ const { bytes } = readWireName(packet, rdataOffset + 6)
81
+ return Buffer.concat([head, bytes])
82
+ }
83
+
84
+ // mbox + txt (two names): RP
85
+ case 17: {
86
+ const { bytes: mbox, end: e1 } = readWireName(packet, rdataOffset)
87
+ const { bytes: txt } = readWireName(packet, e1)
88
+ return Buffer.concat([mbox, txt])
89
+ }
90
+
91
+ // order(2)+pref(2)+flags_str+svc_str+regexp_str+name: NAPTR
92
+ case 35: {
93
+ const head = raw.slice(0, 4)
94
+ let pos = rdataOffset + 4
95
+ const strings = []
96
+ for (let i = 0; i < 3; i++) {
97
+ const slen = packet[pos]
98
+ strings.push(packet.slice(pos, pos + 1 + slen))
99
+ pos += 1 + slen
100
+ }
101
+ const { bytes: name } = readWireName(packet, pos)
102
+ return Buffer.concat([head, ...strings, name])
103
+ }
104
+
105
+ default:
106
+ return raw
107
+ }
108
+ }
109
+
110
+ // ── Query building and parsing ────────────────────────────────────────────────
111
+
112
+ export function buildQuery(name, typeId, classId = 1) {
113
+ const nameBytes = encodeName(name)
114
+ const header = Buffer.alloc(12)
115
+ header.writeUInt16BE(Math.floor(Math.random() * 65536), 0)
116
+ header.writeUInt16BE(0x0100, 2) // RD=1
117
+ header.writeUInt16BE(1, 4) // QDCOUNT=1
118
+ header.writeUInt16BE(1, 10) // ARCOUNT=1 (OPT record)
119
+ const question = Buffer.alloc(nameBytes.length + 4)
120
+ nameBytes.copy(question)
121
+ question.writeUInt16BE(typeId, nameBytes.length)
122
+ question.writeUInt16BE(classId, nameBytes.length + 2)
123
+ // EDNS0 OPT record: root name, type=OPT(41), class=4096 (UDP payload), TTL=0, RDLEN=0
124
+ const opt = Buffer.alloc(11)
125
+ opt[0] = 0x00 // owner = root
126
+ opt.writeUInt16BE(41, 1) // type = OPT
127
+ opt.writeUInt16BE(4096, 3) // UDP payload size
128
+ opt.writeUInt32BE(0, 5) // extended RCODE and flags
129
+ opt.writeUInt16BE(0, 9) // RDLENGTH = 0
130
+ return Buffer.concat([header, question, opt])
131
+ }
132
+
133
+ export function parseResponse(packet) {
134
+ const rcode = packet.readUInt16BE(2) & 0xf
135
+ const qdcount = packet.readUInt16BE(4)
136
+ const ancount = packet.readUInt16BE(6)
137
+
138
+ if (rcode !== 0) return { rcode, answers: [] }
139
+
140
+ let offset = 12
141
+ for (let i = 0; i < qdcount; i++) {
142
+ const { end } = readWireName(packet, offset)
143
+ offset = end + 4
144
+ }
145
+
146
+ const answers = []
147
+ for (let i = 0; i < ancount; i++) {
148
+ const { bytes: ownerBytes, end: nameEnd } = readWireName(packet, offset)
149
+ offset = nameEnd
150
+ const typeId = packet.readUInt16BE(offset)
151
+ const cls = packet.readUInt16BE(offset + 2)
152
+ const rdlen = packet.readUInt16BE(offset + 8)
153
+ const rdataOffset = offset + 10
154
+ const rdataBytes = decompressRdata(packet, rdataOffset, rdlen, typeId)
155
+ offset += 10 + rdlen
156
+ answers.push({ ownerBytes, typeId, cls, rdataBytes })
157
+ }
158
+
159
+ return { rcode: 0, answers }
160
+ }
161
+
162
+ // ── Uncompressed domain name decoding ────────────────────────────────────────
163
+
164
+ /**
165
+ * Decode a length-prefixed (uncompressed) DNS name from a Uint8Array.
166
+ * Returns { fqdn: string, end: number } where end is the offset after the name.
167
+ */
168
+ export function wireUnpackDomain(bytes, offset = 0) {
169
+ const labels = []
170
+ let pos = offset
171
+ while (pos < bytes.length) {
172
+ const len = bytes[pos]
173
+ if (len === 0) {
174
+ pos++
175
+ break
176
+ }
177
+ labels.push(new TextDecoder().decode(bytes.subarray(pos + 1, pos + 1 + len)))
178
+ pos += 1 + len
179
+ }
180
+ return { fqdn: labels.length ? labels.join('.') + '.' : '.', end: pos }
181
+ }
182
+
183
+ /**
184
+ * Pack a fully qualified domain name into wire format (Uint8Array).
185
+ * Domain labels are prefixed with their length; the name ends with a zero byte.
186
+ */
187
+ export function wirePackDomain(fqdn) {
188
+ if (fqdn === '.') return new Uint8Array([0])
189
+ const enc = new TextEncoder()
190
+ const labels = fqdn
191
+ .split('.')
192
+ .filter((p) => p.length > 0)
193
+ .map((p) => {
194
+ const b = enc.encode(p)
195
+ if (b.length > 63) throw new Error(`DNS label exceeds 63 bytes: ${p}`)
196
+ return b
197
+ })
198
+
199
+ const buf = new Uint8Array(labels.reduce((n, b) => n + b.length + 1, 1))
200
+ let offset = 0
201
+ for (const b of labels) {
202
+ buf[offset++] = b.length
203
+ buf.set(b, offset)
204
+ offset += b.length
205
+ }
206
+ buf[offset] = 0
207
+ return buf
208
+ }
209
+
210
+ /**
211
+ * Get wire format rdata for an RR instance.
212
+ * Derived classes should override this with direct RFC 1035 wire encoding.
213
+ * Fallback uses toTinydns() as an intermediate (less efficient but works for all types).
214
+ */
215
+ export function getWireRdata(rrInstance) {
216
+ const line = rrInstance.toTinydns()
217
+ if (!line.startsWith(':')) {
218
+ throw new Error(
219
+ `${rrInstance.get('type')}: getWireRdata() not implemented. Override in rr/${rrInstance.get('type').toLowerCase()}.js`,
220
+ )
221
+ }
222
+ // line: :fqdn:typeId:rdata:ttl:ts:loc\n
223
+ // Extract octal-encoded rdata and decode
224
+ const rdata = line.split(':')[3]
225
+ return rrInstance.octalToUint8Array(rdata ?? '')
226
+ }
227
+
228
+ /**
229
+ * Serialize an RR instance to DNS wire format (RFC 1035).
230
+ * Combines owner name, type, class, TTL, and rdata.
231
+ */
232
+ export function toWire(rrInstance, RRClasses) {
233
+ const rdata = rrInstance.getWireRdata()
234
+ const owner = wirePackDomain(rrInstance.get('owner'))
235
+ const result = new Uint8Array(owner.length + 10 + rdata.length)
236
+ result.set(owner, 0)
237
+ const meta = new DataView(result.buffer, owner.length, 10)
238
+ meta.setUint16(0, rrInstance.getTypeId())
239
+ meta.setUint16(2, RRClasses[rrInstance.get('class')] ?? 1)
240
+ meta.setUint32(4, rrInstance.get('ttl'))
241
+ meta.setUint16(8, rdata.length)
242
+ result.set(rdata, owner.length + 10)
243
+ return result
244
+ }
245
+
246
+ /**
247
+ * Deserialize wire format bytes into an RR instance.
248
+ * Static method wrapper for RR.fromWire().
249
+ */
250
+ export function fromWireBytes(RRConstructor, wireBytes, wireUnpackFn) {
251
+ const instance = new RRConstructor(null)
252
+ const bytes = wireBytes instanceof Uint8Array ? wireBytes : new Uint8Array(wireBytes)
253
+ const { fqdn: owner, end } = wireUnpackFn(bytes, 0)
254
+ const view = new DataView(bytes.buffer, bytes.byteOffset)
255
+ const classNum = view.getUint16(end + 2)
256
+ const RRClasses = { IN: 1, CS: 2, CH: 3, HS: 4, NONE: 254, ANY: 255 }
257
+ const cls = Object.keys(RRClasses).find((k) => RRClasses[k] === classNum) ?? 'IN'
258
+ const ttl = view.getUint32(end + 4)
259
+ const rdlen = view.getUint16(end + 8)
260
+ const rdata = bytes.slice(end + 10, end + 10 + rdlen)
261
+ return instance.fromWire({ owner, cls, ttl, rdata })
262
+ }
263
+
264
+ /**
265
+ * Generic fromWire decoder driven by rdataFields type annotations.
266
+ * Supports u8, u16, u32, fqdn, hex, base64, str, qstr, charstr, qcharstr, charstrs, svcparams, ipv4, ipv6.
267
+ * See rr/TEMPLATE.js for descriptions and example type usage.
268
+ * Last typed field consumes all remaining bytes.
269
+ */
270
+ export function fromWireGeneric(rrInstance, { owner, cls, ttl, rdata }) {
271
+ const result = { owner, ttl, class: cls, type: rrInstance.constructor.typeName }
272
+ const rdataFields = rrInstance.constructor.rdataFields ?? []
273
+ const dv = new DataView(rdata.buffer, rdata.byteOffset)
274
+ let pos = 0
275
+
276
+ for (let i = 0; i < rdataFields.length; i++) {
277
+ const entry = rdataFields[i]
278
+ const fieldName = Array.isArray(entry) ? entry[0] : entry
279
+ const fieldType = Array.isArray(entry) ? entry[1] : null
280
+
281
+ switch (fieldType) {
282
+ case 'u8':
283
+ result[fieldName] = rdata[pos++]
284
+ break
285
+ case 'u16':
286
+ result[fieldName] = dv.getUint16(pos)
287
+ pos += 2
288
+ break
289
+ case 'certtype': {
290
+ const certTypeNum = dv.getUint16(pos)
291
+ const reverse = rrInstance.constructor.CERT_TYPES_REVERSE
292
+ result[fieldName] = reverse?.[certTypeNum] ?? certTypeNum
293
+ pos += 2
294
+ break
295
+ }
296
+ case 'u32':
297
+ result[fieldName] = dv.getUint32(pos)
298
+ pos += 4
299
+ break
300
+ case 'fqdn': {
301
+ const { fqdn, end } = wireUnpackDomain(rdata, pos)
302
+ result[fieldName] = fqdn
303
+ pos = end
304
+ break
305
+ }
306
+ case 'hex':
307
+ result[fieldName] = bytesToHex(rdata.subarray(pos)).toUpperCase()
308
+ pos = rdata.length
309
+ break
310
+ case 'base64':
311
+ result[fieldName] = bytesToBase64(rdata.subarray(pos))
312
+ pos = rdata.length
313
+ break
314
+ case 'str':
315
+ result[fieldName] = new TextDecoder().decode(rdata.subarray(pos))
316
+ pos = rdata.length
317
+ break
318
+ case 'qstr':
319
+ result[fieldName] = new TextDecoder().decode(rdata.subarray(pos))
320
+ pos = rdata.length
321
+ break
322
+ case 'charstr': {
323
+ const strLen = rdata[pos++]
324
+ result[fieldName] = new TextDecoder().decode(rdata.subarray(pos, pos + strLen))
325
+ pos += strLen
326
+ break
327
+ }
328
+ case 'qcharstr': {
329
+ const strLen = rdata[pos++]
330
+ result[fieldName] = new TextDecoder().decode(rdata.subarray(pos, pos + strLen))
331
+ pos += strLen
332
+ break
333
+ }
334
+ case 'charstrs': {
335
+ const parts = []
336
+ while (pos < rdata.length) {
337
+ const strLen = rdata[pos++]
338
+ parts.push(new TextDecoder().decode(rdata.subarray(pos, pos + strLen)))
339
+ pos += strLen
340
+ }
341
+ result[fieldName] = parts.join('')
342
+ break
343
+ }
344
+ case 'svcparams':
345
+ result[fieldName] = svcParamsFromWire(rdata.subarray(pos))
346
+ pos = rdata.length
347
+ break
348
+ case 'ipv4':
349
+ result[fieldName] = [...rdata.subarray(pos, pos + 4)].join('.')
350
+ pos += 4
351
+ break
352
+ case 'ipv6': {
353
+ const parts = []
354
+ for (let j = 0; j < 16; j += 2) {
355
+ parts.push(
356
+ dv
357
+ .getUint16(pos + j)
358
+ .toString(16)
359
+ .padStart(4, '0'),
360
+ )
361
+ }
362
+ result[fieldName] = parts.join(':')
363
+ pos += 16
364
+ break
365
+ }
366
+ default:
367
+ result[fieldName] = rdata[pos++]
368
+ break
369
+ }
370
+ }
371
+
372
+ return new rrInstance.constructor(result)
373
+ }
374
+
375
+ // ── SVCB/HTTPS parameter encoding (RFC 9460) ─────────────────────────────────
376
+
377
+ const SVCPARAM_KEYS = {
378
+ mandatory: 0,
379
+ alpn: 1,
380
+ 'no-default-alpn': 2,
381
+ port: 3,
382
+ ipv4hint: 4,
383
+ ech: 5,
384
+ ipv6hint: 6,
385
+ }
386
+
387
+ /**
388
+ * Encode SVCB/HTTPS params string (e.g. 'alpn="h2,h3" port="8443"') to wire bytes.
389
+ */
390
+ export function svcParamsToWire(paramsStr) {
391
+ if (!paramsStr || !paramsStr.trim()) return new Uint8Array(0)
392
+
393
+ const parts = []
394
+ // Match: key, key=unquoted, or key="quoted" tokens
395
+ const re = /([^\s=]+)(?:=(?:"([^"]*)"|(\S*)))?(?=\s|$)/g
396
+ let m
397
+ while ((m = re.exec(paramsStr.trim())) !== null) {
398
+ const key = m[1].toLowerCase()
399
+ const val = m[2] ?? m[3] ?? ''
400
+ const keyId = SVCPARAM_KEYS[key]
401
+ if (keyId === undefined) continue
402
+
403
+ let valBytes
404
+ if (keyId === 1) {
405
+ // alpn: comma-separated list, each entry length-prefixed
406
+ const enc = new TextEncoder()
407
+ const entries = val.split(',').map((e) => enc.encode(e.trim()))
408
+ const total = entries.reduce((s, e) => s + 1 + e.length, 0)
409
+ valBytes = new Uint8Array(total)
410
+ let p = 0
411
+ for (const e of entries) {
412
+ valBytes[p++] = e.length
413
+ valBytes.set(e, p)
414
+ p += e.length
415
+ }
416
+ } else if (keyId === 2) {
417
+ // no-default-alpn: no value
418
+ valBytes = new Uint8Array(0)
419
+ } else if (keyId === 3) {
420
+ // port: uint16
421
+ valBytes = new Uint8Array(2)
422
+ new DataView(valBytes.buffer).setUint16(0, parseInt(val, 10))
423
+ } else if (keyId === 4) {
424
+ // ipv4hint: one or more IPv4 addresses
425
+ const addrs = val.split(',').map((a) => a.trim().split('.').map(Number))
426
+ valBytes = new Uint8Array(addrs.length * 4)
427
+ addrs.forEach((addr, i) => addr.forEach((b, j) => (valBytes[i * 4 + j] = b)))
428
+ } else if (keyId === 5) {
429
+ // ech: base64-encoded ECH config
430
+ valBytes = base64ToBytes(val)
431
+ } else if (keyId === 6) {
432
+ // ipv6hint: one or more IPv6 addresses (expand compressed form before encoding)
433
+ const addrs = val.split(',').map((a) => hexToBytes(expandIPv6(a.trim(), '')))
434
+ valBytes = new Uint8Array(addrs.length * 16)
435
+ addrs.forEach((addr, i) => valBytes.set(addr, i * 16))
436
+ } else {
437
+ valBytes = new TextEncoder().encode(val)
438
+ }
439
+
440
+ const param = new Uint8Array(4 + valBytes.length)
441
+ new DataView(param.buffer).setUint16(0, keyId)
442
+ new DataView(param.buffer).setUint16(2, valBytes.length)
443
+ param.set(valBytes, 4)
444
+ parts.push(param)
445
+ }
446
+
447
+ // Sort by key ID (SvcParams MUST be in ascending order per RFC 9460)
448
+ parts.sort((a, b) => new DataView(a.buffer).getUint16(0) - new DataView(b.buffer).getUint16(0))
449
+
450
+ const total = parts.reduce((s, p) => s + p.length, 0)
451
+ const result = new Uint8Array(total)
452
+ let pos = 0
453
+ for (const p of parts) {
454
+ result.set(p, pos)
455
+ pos += p.length
456
+ }
457
+ return result
458
+ }
459
+
460
+ const SVCPARAM_KEY_NAMES = {
461
+ 0: 'mandatory',
462
+ 1: 'alpn',
463
+ 2: 'no-default-alpn',
464
+ 3: 'port',
465
+ 4: 'ipv4hint',
466
+ 5: 'ech',
467
+ 6: 'ipv6hint',
468
+ }
469
+
470
+ /**
471
+ * Decode SVCB/HTTPS wire params bytes back to a human-readable params string.
472
+ */
473
+ export function svcParamsFromWire(bytes) {
474
+ if (!bytes || bytes.length === 0) return ''
475
+ const parts = []
476
+ let pos = 0
477
+ while (pos + 4 <= bytes.length) {
478
+ const dv = new DataView(bytes.buffer, bytes.byteOffset + pos)
479
+ const keyId = dv.getUint16(0)
480
+ const valLen = dv.getUint16(2)
481
+ const val = bytes.subarray(pos + 4, pos + 4 + valLen)
482
+ pos += 4 + valLen
483
+
484
+ const keyName = SVCPARAM_KEY_NAMES[keyId] ?? `key${keyId}`
485
+ if (keyId === 1) {
486
+ // alpn: comma-separated length-prefixed entries
487
+ const entries = []
488
+ let p = 0
489
+ while (p < val.length) {
490
+ const len = val[p++]
491
+ entries.push(new TextDecoder().decode(val.subarray(p, p + len)))
492
+ p += len
493
+ }
494
+ parts.push(`alpn="${entries.join(',')}"`)
495
+ } else if (keyId === 2) {
496
+ parts.push('no-default-alpn')
497
+ } else if (keyId === 3) {
498
+ parts.push(`port=${new DataView(val.buffer, val.byteOffset).getUint16(0)}`)
499
+ } else if (keyId === 4) {
500
+ const addrs = []
501
+ for (let i = 0; i < val.length; i += 4) addrs.push([...val.subarray(i, i + 4)].join('.'))
502
+ parts.push(`ipv4hint=${addrs.join(',')}`)
503
+ } else if (keyId === 5) {
504
+ parts.push(`ech=${btoa([...val].map((b) => String.fromCharCode(b)).join(''))}`)
505
+ } else if (keyId === 6) {
506
+ const addrs = []
507
+ for (let i = 0; i < val.length; i += 16) {
508
+ const dv16 = new DataView(val.buffer, val.byteOffset + i)
509
+ const groups = []
510
+ for (let j = 0; j < 16; j += 2) groups.push(dv16.getUint16(j).toString(16).padStart(4, '0'))
511
+ addrs.push(groups.join(':'))
512
+ }
513
+ parts.push(`ipv6hint=${addrs.join(',')}`)
514
+ } else {
515
+ parts.push(`${keyName}=${[...val].map((b) => b.toString(16).padStart(2, '0')).join('')}`)
516
+ }
517
+ }
518
+ return parts.join(' ')
519
+ }
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@nictool/dns-resource-record",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "DNS Resource Records",
5
5
  "main": "index.js",
6
+ "exports": {
7
+ ".": {
8
+ "browser": "./dist/dns-rr.min.js",
9
+ "default": "./index.js"
10
+ }
11
+ },
6
12
  "files": [
13
+ "dist",
7
14
  "lib",
8
15
  "rr",
9
16
  "CHANGELOG.md",
@@ -11,14 +18,15 @@
11
18
  ],
12
19
  "type": "module",
13
20
  "scripts": {
14
- "build": "node lib/readme.js",
15
- "clean": "rm -rf coverage node_modules package-lock.json",
21
+ "build": "npm run build:browser",
22
+ "build:browser": "npx rollup -i index.js -o dist/dns-rr.js -f es && esbuild dist/dns-rr.js --minify --sourcemap --outfile=dist/dns-rr.min.js && rm dist/dns-rr.js",
23
+ "clean": "rm -rf coverage dist node_modules package-lock.json",
16
24
  "format:check": "npm run prettier; npm run lint",
17
25
  "format": "npm run prettier -- --write && npm run lint:fix",
18
- "lint": "npx eslint **/*.js",
26
+ "lint": "npx eslint **/*.js --no-warn-ignored",
19
27
  "lint:fix": "npm run lint -- --fix",
20
- "prettier": "npx prettier --ignore-path .gitignore --check .",
21
- "prettier:fix": "npx prettier --ignore-path .gitignore --write .",
28
+ "prettier": "npx prettier --check .",
29
+ "prettier:fix": "npx prettier --write .",
22
30
  "test": "node --test",
23
31
  "test:coverage": "node --test --experimental-test-coverage",
24
32
  "test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info",
@@ -49,6 +57,15 @@
49
57
  "url": "https://github.com/NicTool/dns-resource-record/issues"
50
58
  },
51
59
  "homepage": "https://github.com/NicTool/dns-resource-record#readme",
60
+ "engines": {
61
+ "node": ">=22"
62
+ },
63
+ "browserslist": [
64
+ "chrome >= 74",
65
+ "firefox >= 90",
66
+ "safari >= 14.1",
67
+ "edge >= 79"
68
+ ],
52
69
  "devDependencies": {
53
70
  "@eslint/js": "^10.0.1",
54
71
  "eslint": "^10.2.1",
package/rr/TEMPLATE.js ADDED
@@ -0,0 +1,124 @@
1
+ import RR from '../rr.js'
2
+
3
+ /**
4
+ * DNS Resource Record Template
5
+ *
6
+ * Use this as a starting point for creating a new Resource Record (RR) class.
7
+ * Replace 'TEMPLATE' with the actual record type (e.g., 'MX', 'SRV').
8
+ *
9
+ * ## Mandates
10
+ * - Every new RR MUST extend the `RR` class.
11
+ * - 100% test coverage is required for all new PRs.
12
+ * - Follow established patterns for naming and implementation.
13
+ * - **Registration**: You MUST update `index.js` to import and export the new class and add it to the `classes` array.
14
+ * - **Tests**: You MUST create a corresponding test file in `test/rr/<type>.js` (mirroring an existing test file like `test/rr/a.js`).
15
+ *
16
+ * ## `rdataFields` and Avoiding Boilerplate
17
+ * The `rdataFields` static property defines the fields in the RDATA section.
18
+ * It is an array where each element can be:
19
+ * - A string: The field name.
20
+ * - An array: `[fieldName, type]`
21
+ *
22
+ * Supported types for `setTypedValue`:
23
+ * - `u8`, `u16`, `u32`: Unsigned integers of 8, 16, or 32 bits.
24
+ * - `fqdn`: A fully qualified domain name.
25
+ * - `qstr`: Quoted string field (BIND quoting behavior).
26
+ * - `charstr`: DNS length-prefixed character-string (max 255 bytes).
27
+ * - `qcharstr`: Quoted DNS length-prefixed character-string.
28
+ * - `charstrs`: Concatenated DNS character-strings.
29
+ * - `svcparams`: SVCB/HTTPS params string.
30
+ * - `base64`: Base64 encoded data.
31
+ * - `hex`: Hexadecimal encoded data.
32
+ * - `str`: A simple string.
33
+ * - `ipv4`: An IPv4 address.
34
+ * - `ipv6`: An IPv6 address.
35
+ *
36
+ * If you provide a type in `rdataFields`, the base `RR` class automatically
37
+ * handles validation and setting of that field. You only need to implement
38
+ * a custom `set<FieldName>` method if you need more complex validation
39
+ * logic than what the default types provide.
40
+ */
41
+
42
+ export default class TEMPLATE extends RR {
43
+ // The DNS record type name (e.g., 'A', 'MX', 'TXT')
44
+ static typeName = 'TEMPLATE'
45
+ static typeId = 0
46
+ static RFCs = []
47
+ static tags = []
48
+
49
+ // The character used in tinydns format (optional)
50
+ static tinydnsType = 'T'
51
+
52
+ // Define the RDATA fields.
53
+ // Using types here avoids the need for manual setters in many cases.
54
+ static rdataFields = [
55
+ ['field1', 'u16'],
56
+ ['field2', 'fqdn'],
57
+ ]
58
+
59
+ constructor(opts) {
60
+ super(opts)
61
+ }
62
+
63
+ /****** Resource record specific setters *******/
64
+
65
+ /*
66
+ // Example of a custom setter. Only implement if default validation
67
+ // from `rdataFields` is insufficient.
68
+ setField1(val) {
69
+ if (val === undefined) val = this?.default?.field1
70
+ if (val === undefined) this.throwHelp('TEMPLATE: field1 is required')
71
+ this.is16bitInt('TEMPLATE', 'field1', val)
72
+ this.set('field1', parseInt(val, 10))
73
+ }
74
+ */
75
+
76
+ /****** Metadata (Required) *******/
77
+
78
+ getDescription() {
79
+ return 'Short description of the TEMPLATE record'
80
+ }
81
+
82
+ getCanonical() {
83
+ // Returns a sample object representing a valid record.
84
+ // This is used for documentation and testing.
85
+ return {
86
+ owner: 'example.com.',
87
+ ttl: 3600,
88
+ class: 'IN',
89
+ type: 'TEMPLATE',
90
+ field1: 10,
91
+ field2: 'target.example.com.',
92
+ }
93
+ }
94
+
95
+ /****** IMPORTERS / EXPORTERS (Recommended) *******/
96
+
97
+ /*
98
+ // Optional: Override if generic implementation in RR.js is insufficient
99
+ fromTinydns({ tinyline }) {
100
+ // Implementation...
101
+ }
102
+ */
103
+
104
+ /*
105
+ // Optional: Override if generic implementation in RR.js is insufficient
106
+ toTinydns() {
107
+ // Implementation...
108
+ }
109
+ */
110
+
111
+ getWireRdata() {
112
+ // MUST be implemented for binary/wire format support.
113
+ // Returns a Uint8Array.
114
+ // Example:
115
+ /*
116
+ const domain = this.wirePackDomain(this.get('field2'))
117
+ const result = new Uint8Array(2 + domain.length)
118
+ new DataView(result.buffer).setUint16(0, this.get('field1'))
119
+ result.set(domain, 2)
120
+ return result
121
+ */
122
+ throw new Error('getWireRdata not implemented')
123
+ }
124
+ }