@sdxc/doh 2026.10.16 → 2026.10.25

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/README.md CHANGED
@@ -82,22 +82,28 @@ if (!verdict.allowed) verdict.reason; // "not-authorized", "forbidden", "lookup-
82
82
  Looks up `name`'s records of `type` with one `fetch` and no state. Send an internationalized name
83
83
  in its ASCII (punycode) form. Options:
84
84
 
85
- - `resolver`: `CLOUDFLARE` (default), `GOOGLE`, or any `{ url, format: "json" }`
86
- - `dnssec`: sets `do=1`
87
- - `checkingDisabled`: sets `cd=1`
85
+ - `resolver`: `CLOUDFLARE` (default), `GOOGLE`, their RFC 8484 wire-format endpoints
86
+ `CLOUDFLARE_WIRE` and `GOOGLE_WIRE`, or any `{ url, format: "json" | "wire" }`
87
+ - `dnssec`: sets the DO bit
88
+ - `checkingDisabled`: sets the CD bit
88
89
  - `signal`: aborts the request
89
90
  - `timeoutMs`: abandons the request as a `TransportError`, default `5000`
90
91
 
91
92
  The answer holds `records`, `unparsed` (records of the type whose data did not parse, with their
92
93
  raw `data`), `chain` (the CNAMEs followed to reach `records`), `ttl` (smallest across `records`
93
94
  and `unparsed`, `null` when both are empty), `authenticated` (the AD flag), `truncated` (the TC
94
- flag), `name`, `type` and `durationMs`. Names come back lowercased without the trailing dot, and
95
+ flag), `extendedErrors` (RFC 8914 `{ code, text }` from a `"wire"` resolver), `name`, `type` and
96
+ `durationMs`. Names come back lowercased without the trailing dot, and
95
97
  AAAA addresses in RFC 5952 form.
96
98
 
97
99
  Typed record fields: `A` and `AAAA` (`address`), `CNAME`, `PTR` and `DNAME` (`target`), `NS`
98
100
  (`host`), `MX` (`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`flags`, `critical`,
99
101
  `tag`, `value`), `SRV` (`priority`, `weight`, `port`, `target`), `SOA` (`primary`, `mailbox`,
100
- `serial`, `refresh`, `retry`, `expire`, `minimum`). Any other type gives a raw `data` string.
102
+ `serial`, `refresh`, `retry`, `expire`, `minimum`), `SVCB` and `HTTPS` (`priority`, `target`,
103
+ `params`), `TLSA` (`usage`, `selector`, `matchingType`, `certificateAssociation`), `DS` and `CDS`
104
+ (`keyTag`, `algorithm`, `digestType`, `digest`), `DNSKEY` and `CDNSKEY` (`flags`, `zoneKey`,
105
+ `secureEntryPoint`, `protocol`, `algorithm`, `publicKey`, `keyTag`). Any other type gives a raw
106
+ `data` string.
101
107
 
102
108
  ### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
103
109
 
@@ -110,19 +116,21 @@ Whether `name` aliases `target`, directly or through up to eight CNAME hops, com
110
116
  case-insensitively without the trailing dot. It holds even when the target resolves to nothing.
111
117
  NXDOMAIN is `success(false)`.
112
118
 
113
- ### `CLOUDFLARE` / `GOOGLE`
119
+ ### `CLOUDFLARE` / `GOOGLE` / `CLOUDFLARE_WIRE` / `GOOGLE_WIRE`
114
120
 
115
- The resolvers `https://cloudflare-dns.com/dns-query` and `https://dns.google/resolve`, ready to
116
- pass as `resolver`.
121
+ The resolvers `https://cloudflare-dns.com/dns-query` and `https://dns.google/resolve` in the JSON
122
+ API, and `https://cloudflare-dns.com/dns-query` and `https://dns.google/dns-query` in RFC 8484
123
+ wire format, which also reports RFC 8914 extended errors.
117
124
 
118
125
  ### Errors
119
126
 
120
127
  | Class | When | Extra fields |
121
128
  | -------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------- |
122
129
  | `DoHError` | Base of every failure | |
130
+ | `AnsweredError` | Base of the three below | `extendedErrors`: RFC 8914 `{ code, text }`, from `"wire"` only |
123
131
  | `NameNotFoundError` | NXDOMAIN (RCODE 3) | `ttl`: the SOA's negative-caching TTL or `null`; `authenticated` |
124
132
  | `ServerFailureError` | SERVFAIL (RCODE 2), DNSSEC validation failures included | |
125
- | `ResponseCodeError` | Any other non-zero RCODE | `rcode` |
133
+ | `ResponseCodeError` | Any other non-zero RCODE, extended RCODEs included | `rcode` |
126
134
  | `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not a DNS answer | `status`: the HTTP status, `null` when no response arrived |
127
135
 
128
136
  ### `parseCaaProperty(record): CAA.Property` from `./caa`
@@ -177,7 +185,8 @@ does: `unreadable` when the set holds a record that does not parse, and `lookup-
177
185
  ### Types
178
186
 
179
187
  - `DoH`: `RecordType`, `Resolver`, `ResolveOptions`, `Answer<Type>`, `RecordFor<Type>`,
180
- `RecordBase`, `UnknownRecord`, and one alias per typed record (`ARecord` through `SOARecord`).
188
+ `RecordBase`, `UnknownRecord`, `ExtendedError`, and one alias per typed record (`ARecord`
189
+ through `CDNSKEYRecord`).
181
190
  - `CAA` (from `./caa`): `Record`, `Parameter`, `Property` and its three variants, `RelevantSet`,
182
191
  `Request`, `Decision`, `Verdict`, and one interface per reason. Every one but `LookupFailed` is
183
192
  plain data, ready to log or store.
package/dist/errors.d.ts CHANGED
@@ -5,12 +5,24 @@
5
5
  * @author [Sergio Xalambrí](https://sergiodxa.com)
6
6
  * @copyright Sergio Xalambrí 2026
7
7
  */
8
+ import type { DoH } from "./types.js";
8
9
  /** Every failure `resolve` returns; `instanceof` on it narrows to a lookup failure. */
9
10
  export declare class DoHError extends Error {
10
11
  name: string;
11
12
  }
13
+ /** A failure the resolver answered with a DNS message, so it may say why with RFC 8914 extended errors. */
14
+ export declare class AnsweredError extends DoHError {
15
+ name: string;
16
+ /** The resolver's RFC 8914 extended errors; a `json` resolver's are always empty. */
17
+ readonly extendedErrors: DoH.ExtendedError[];
18
+ /**
19
+ * @param message - What went wrong, the first extended error appended when there is one.
20
+ * @param extendedErrors - The extended errors the answer carried.
21
+ */
22
+ constructor(message: string, extendedErrors: DoH.ExtendedError[]);
23
+ }
12
24
  /** RCODE 3 (NXDOMAIN): the name does not exist. */
13
- export declare class NameNotFoundError extends DoHError {
25
+ export declare class NameNotFoundError extends AnsweredError {
14
26
  name: string;
15
27
  /**
16
28
  * Seconds the absence may be cached: the smaller of the SOA's TTL and its minimum
@@ -23,24 +35,29 @@ export declare class NameNotFoundError extends DoHError {
23
35
  * @param queried - The name that was looked up.
24
36
  * @param ttl - The negative-caching TTL, when known.
25
37
  * @param authenticated - Whether the answer carried the AD flag.
38
+ * @param extendedErrors - The answer's RFC 8914 extended errors.
26
39
  */
27
- constructor(queried: string, ttl: number | null, authenticated?: boolean);
40
+ constructor(queried: string, ttl: number | null, authenticated?: boolean, extendedErrors?: DoH.ExtendedError[]);
28
41
  }
29
42
  /** RCODE 2 (SERVFAIL): the resolver could not answer, a DNSSEC validation failure included. */
30
- export declare class ServerFailureError extends DoHError {
43
+ export declare class ServerFailureError extends AnsweredError {
31
44
  name: string;
32
- /** @param queried - The name that was looked up. */
33
- constructor(queried: string);
45
+ /**
46
+ * @param queried - The name that was looked up.
47
+ * @param extendedErrors - The answer's RFC 8914 extended errors, such as `6` for DNSSEC Bogus.
48
+ */
49
+ constructor(queried: string, extendedErrors?: DoH.ExtendedError[]);
34
50
  }
35
51
  /** Any other non-zero RCODE: FORMERR, NOTIMP, REFUSED and the rest. */
36
- export declare class ResponseCodeError extends DoHError {
52
+ export declare class ResponseCodeError extends AnsweredError {
37
53
  name: string;
38
54
  readonly rcode: number;
39
55
  /**
40
56
  * @param queried - The name that was looked up.
41
- * @param rcode - The DNS response code the resolver returned.
57
+ * @param rcode - The DNS response code the resolver returned, extended RCODEs included.
58
+ * @param extendedErrors - The answer's RFC 8914 extended errors.
42
59
  */
43
- constructor(queried: string, rcode: number);
60
+ constructor(queried: string, rcode: number, extendedErrors?: DoH.ExtendedError[]);
44
61
  }
45
62
  /** The request produced no DNS answer: a network error, a timeout, a non-2xx, or a body that is not the envelope. */
46
63
  export declare class TransportError extends DoHError {
package/dist/errors.js CHANGED
@@ -1,16 +1,28 @@
1
- /**
2
- * The failures a lookup returns, one class per meaning, so a caller tells "the name is
3
- * gone" from "the resolver is down" with `instanceof` instead of matching messages.
4
- *
5
- * @author [Sergio Xalambrí](https://sergiodxa.com)
6
- * @copyright Sergio Xalambrí 2026
7
- */
8
1
  /** Every failure `resolve` returns; `instanceof` on it narrows to a lookup failure. */
9
2
  export class DoHError extends Error {
10
3
  name = "DoHError";
11
4
  }
5
+ /** A failure the resolver answered with a DNS message, so it may say why with RFC 8914 extended errors. */
6
+ export class AnsweredError extends DoHError {
7
+ name = "AnsweredError";
8
+ /** The resolver's RFC 8914 extended errors; a `json` resolver's are always empty. */
9
+ extendedErrors;
10
+ /**
11
+ * @param message - What went wrong, the first extended error appended when there is one.
12
+ * @param extendedErrors - The extended errors the answer carried.
13
+ */
14
+ constructor(message, extendedErrors) {
15
+ let [first] = extendedErrors;
16
+ super(first ? `${message}; extended error ${describe(first)}` : message);
17
+ this.extendedErrors = extendedErrors;
18
+ }
19
+ }
20
+ /** An extended error as one phrase: its code, and the resolver's text when it sent some. */
21
+ function describe(error) {
22
+ return error.text ? `${error.code}: ${error.text}` : String(error.code);
23
+ }
12
24
  /** RCODE 3 (NXDOMAIN): the name does not exist. */
13
- export class NameNotFoundError extends DoHError {
25
+ export class NameNotFoundError extends AnsweredError {
14
26
  name = "NameNotFoundError";
15
27
  /**
16
28
  * Seconds the absence may be cached: the smaller of the SOA's TTL and its minimum
@@ -23,31 +35,36 @@ export class NameNotFoundError extends DoHError {
23
35
  * @param queried - The name that was looked up.
24
36
  * @param ttl - The negative-caching TTL, when known.
25
37
  * @param authenticated - Whether the answer carried the AD flag.
38
+ * @param extendedErrors - The answer's RFC 8914 extended errors.
26
39
  */
27
- constructor(queried, ttl, authenticated = false) {
28
- super(`${queried} does not exist (NXDOMAIN)`);
40
+ constructor(queried, ttl, authenticated = false, extendedErrors = []) {
41
+ super(`${queried} does not exist (NXDOMAIN)`, extendedErrors);
29
42
  this.ttl = ttl;
30
43
  this.authenticated = authenticated;
31
44
  }
32
45
  }
33
46
  /** RCODE 2 (SERVFAIL): the resolver could not answer, a DNSSEC validation failure included. */
34
- export class ServerFailureError extends DoHError {
47
+ export class ServerFailureError extends AnsweredError {
35
48
  name = "ServerFailureError";
36
- /** @param queried - The name that was looked up. */
37
- constructor(queried) {
38
- super(`The resolver failed to answer for ${queried} (SERVFAIL)`);
49
+ /**
50
+ * @param queried - The name that was looked up.
51
+ * @param extendedErrors - The answer's RFC 8914 extended errors, such as `6` for DNSSEC Bogus.
52
+ */
53
+ constructor(queried, extendedErrors = []) {
54
+ super(`The resolver failed to answer for ${queried} (SERVFAIL)`, extendedErrors);
39
55
  }
40
56
  }
41
57
  /** Any other non-zero RCODE: FORMERR, NOTIMP, REFUSED and the rest. */
42
- export class ResponseCodeError extends DoHError {
58
+ export class ResponseCodeError extends AnsweredError {
43
59
  name = "ResponseCodeError";
44
60
  rcode;
45
61
  /**
46
62
  * @param queried - The name that was looked up.
47
- * @param rcode - The DNS response code the resolver returned.
63
+ * @param rcode - The DNS response code the resolver returned, extended RCODEs included.
64
+ * @param extendedErrors - The answer's RFC 8914 extended errors.
48
65
  */
49
- constructor(queried, rcode) {
50
- super(`The resolver answered ${queried} with response code ${rcode}`);
66
+ constructor(queried, rcode, extendedErrors = []) {
67
+ super(`The resolver answered ${queried} with response code ${rcode}`, extendedErrors);
51
68
  this.rcode = rcode;
52
69
  }
53
70
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The HTTP exchange both encodings share, and the response shape each reads its body into,
3
+ * so `resolve` classifies RCODEs and splits answers the same way whichever format the
4
+ * resolver speaks.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { DoH } from "./types.js";
11
+ import { TransportError } from "./errors.js";
12
+ /** One resource record of a response, its data in presentation or RFC 3597 generic form. */
13
+ export interface MessageRecord {
14
+ /** Lowercased, without the trailing dot. */
15
+ name: string;
16
+ /** The IANA type code. */
17
+ type: number;
18
+ ttl: number;
19
+ data: string;
20
+ }
21
+ /** What a response says, read out of either encoding. */
22
+ export interface Message {
23
+ /** The full RCODE, the OPT record's extended bits included. */
24
+ rcode: number;
25
+ authenticated: boolean;
26
+ truncated: boolean;
27
+ answer: MessageRecord[];
28
+ authority: MessageRecord[];
29
+ extendedErrors: DoH.ExtendedError[];
30
+ }
31
+ /** What one query sends: the name and type as asked, and the DNSSEC flags. */
32
+ export interface Query {
33
+ name: string;
34
+ /** The canonical mnemonic or `TYPEnnn`. */
35
+ type: string;
36
+ dnssec: boolean;
37
+ checkingDisabled: boolean;
38
+ }
39
+ /** The signal that aborts a request and the timeout that may be why. */
40
+ export interface Deadline {
41
+ signal: AbortSignal;
42
+ timeout: AbortSignal;
43
+ timeoutMs: number;
44
+ }
45
+ /**
46
+ * Sends one request to the resolver, mapping every way it can fail before a 2xx response
47
+ * arrives, and a body that cannot be read, to a `TransportError`.
48
+ *
49
+ * @param url - The query URL, the question already in it.
50
+ * @param accept - The media type asked for.
51
+ * @param deadline - The caller's abort signal joined with the timeout.
52
+ * @param read - Reads the body, failing when it is not the encoding asked for.
53
+ */
54
+ export declare function exchange<Body>(url: URL, accept: string, deadline: Deadline, read: (response: Response) => Promise<Body>): Promise<Result<{
55
+ body: Body;
56
+ status: number;
57
+ }, TransportError>>;
@@ -0,0 +1,34 @@
1
+ import { failure, success } from "@sdxc/result";
2
+ import { TransportError } from "./errors.js";
3
+ /**
4
+ * Sends one request to the resolver, mapping every way it can fail before a 2xx response
5
+ * arrives, and a body that cannot be read, to a `TransportError`.
6
+ *
7
+ * @param url - The query URL, the question already in it.
8
+ * @param accept - The media type asked for.
9
+ * @param deadline - The caller's abort signal joined with the timeout.
10
+ * @param read - Reads the body, failing when it is not the encoding asked for.
11
+ */
12
+ export async function exchange(url, accept, deadline, read) {
13
+ let response;
14
+ try {
15
+ response = await fetch(url, { headers: { Accept: accept }, signal: deadline.signal });
16
+ }
17
+ catch (error) {
18
+ let message = deadline.timeout.aborted
19
+ ? `The DNS query timed out after ${deadline.timeoutMs}ms`
20
+ : `The DNS query failed: ${error instanceof Error ? error.message : String(error)}`;
21
+ return failure(new TransportError(message, null, { cause: error }));
22
+ }
23
+ if (!response.ok) {
24
+ return failure(new TransportError(`The resolver answered HTTP ${response.status}`, response.status));
25
+ }
26
+ try {
27
+ return success({ body: await read(response), status: response.status });
28
+ }
29
+ catch (error) {
30
+ return failure(new TransportError(`The resolver's response is not ${accept}`, response.status, {
31
+ cause: error,
32
+ }));
33
+ }
34
+ }
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * @copyright Sergio Xalambrí 2026
8
8
  */
9
9
  export type { DoH } from "./types.js";
10
- export { DoHError, NameNotFoundError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
10
+ export { AnsweredError, DoHError, NameNotFoundError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
11
11
  export { resolve } from "./resolve.js";
12
- export { CLOUDFLARE, GOOGLE } from "./resolvers.js";
12
+ export { CLOUDFLARE, CLOUDFLARE_WIRE, GOOGLE, GOOGLE_WIRE } from "./resolvers.js";
13
13
  export { checkCname, verifyTxtRecord } from "./verify.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * @author [Sergio Xalambrí](https://sergiodxa.com)
7
7
  * @copyright Sergio Xalambrí 2026
8
8
  */
9
- export { DoHError, NameNotFoundError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
9
+ export { AnsweredError, DoHError, NameNotFoundError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
10
10
  export { resolve } from "./resolve.js";
11
- export { CLOUDFLARE, GOOGLE } from "./resolvers.js";
11
+ export { CLOUDFLARE, CLOUDFLARE_WIRE, GOOGLE, GOOGLE_WIRE } from "./resolvers.js";
12
12
  export { checkCname, verifyTxtRecord } from "./verify.js";
package/dist/json.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The DoH JSON API (`application/dns-json`) as Cloudflare and Google answer it: the
3
+ * question goes in the query string, and the validated envelope is read into the response
4
+ * shape `resolve` works from.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { Deadline, Message, Query } from "./exchange.js";
11
+ import type { DoH } from "./types.js";
12
+ import { TransportError } from "./errors.js";
13
+ /**
14
+ * Asks a JSON resolver one question. The envelope carries no extended errors, so a JSON
15
+ * response reports none.
16
+ *
17
+ * @param resolver - The endpoint, its URL taking `name`, `type`, `do` and `cd` parameters.
18
+ * @param query - The question and flags.
19
+ * @param deadline - Aborts the request.
20
+ */
21
+ export declare function queryJson(resolver: DoH.Resolver, query: Query, deadline: Deadline): Promise<Result<Message, TransportError>>;
package/dist/json.js ADDED
@@ -0,0 +1,66 @@
1
+ import { failure, isFailure, success } from "@sdxc/result";
2
+ import * as s from "remix/data-schema";
3
+ import { TransportError } from "./errors.js";
4
+ import { exchange } from "./exchange.js";
5
+ /** One resource record as the JSON API writes it. */
6
+ const WIRE_RECORD_SCHEMA = s.object({
7
+ name: s.string(),
8
+ type: s.number(),
9
+ TTL: s.number(),
10
+ data: s.string(),
11
+ });
12
+ /** The members of the JSON envelope this package reads; Cloudflare and Google both send them. */
13
+ const ENVELOPE_SCHEMA = s.object({
14
+ Status: s.number(),
15
+ TC: s.optional(s.boolean()),
16
+ AD: s.optional(s.boolean()),
17
+ Answer: s.optional(s.array(WIRE_RECORD_SCHEMA)),
18
+ Authority: s.optional(s.array(WIRE_RECORD_SCHEMA)),
19
+ });
20
+ /** Folds a name the way every owner name in an answer is reported. */
21
+ function normalizeName(name) {
22
+ let lower = name.toLowerCase();
23
+ return lower.length > 1 && lower.endsWith(".") ? lower.slice(0, -1) : lower;
24
+ }
25
+ /** A record of the envelope in the shape both encodings share. */
26
+ function toRecord(record) {
27
+ return {
28
+ name: normalizeName(record.name),
29
+ type: record.type,
30
+ ttl: record.TTL,
31
+ data: record.data,
32
+ };
33
+ }
34
+ /**
35
+ * Asks a JSON resolver one question. The envelope carries no extended errors, so a JSON
36
+ * response reports none.
37
+ *
38
+ * @param resolver - The endpoint, its URL taking `name`, `type`, `do` and `cd` parameters.
39
+ * @param query - The question and flags.
40
+ * @param deadline - Aborts the request.
41
+ */
42
+ export async function queryJson(resolver, query, deadline) {
43
+ let url = new URL(resolver.url);
44
+ url.searchParams.set("name", query.name);
45
+ url.searchParams.set("type", /^TYPE\d+$/.test(query.type) ? query.type.slice(4) : query.type);
46
+ if (query.dnssec)
47
+ url.searchParams.set("do", "1");
48
+ if (query.checkingDisabled)
49
+ url.searchParams.set("cd", "1");
50
+ let response = await exchange(url, "application/dns-json", deadline, (body) => body.json());
51
+ if (isFailure(response))
52
+ return response;
53
+ let parsed = s.parseSafe(ENVELOPE_SCHEMA, response.data.body);
54
+ if (!parsed.success) {
55
+ return failure(new TransportError("The resolver's response is not a DNS JSON answer", response.data.status));
56
+ }
57
+ let envelope = parsed.value;
58
+ return success({
59
+ rcode: envelope.Status,
60
+ authenticated: envelope.AD ?? false,
61
+ truncated: envelope.TC ?? false,
62
+ answer: (envelope.Answer ?? []).map(toRecord),
63
+ authority: (envelope.Authority ?? []).map(toRecord),
64
+ extendedErrors: [],
65
+ });
66
+ }
package/dist/resolve.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Looks a name up over the DoH JSON API: one `fetch` to the resolver, the envelope
3
- * validated, the RCODE classified into a typed failure, and the answer split into the
2
+ * Looks a name up over DNS over HTTPS: one `fetch` to the resolver in its JSON or RFC 8484
3
+ * wire format, the RCODE classified into a typed failure, and the answer split into the
4
4
  * records of the asked type and the CNAME chain that led to them.
5
5
  *
6
6
  * @author [Sergio Xalambrí](https://sergiodxa.com)
@@ -21,5 +21,6 @@ import type { DoH } from "./types.js";
21
21
  * @returns The answer, or the typed reason there is none.
22
22
  * @template Type - The record type, which types the records returned.
23
23
  * @example let answer = await resolve("example.com", "MX");
24
+ * @example let answer = await resolve("example.com", "HTTPS", { resolver: CLOUDFLARE_WIRE });
24
25
  */
25
26
  export declare function resolve<Type extends DoH.RecordType>(name: string, type: Type, options?: DoH.ResolveOptions): Promise<Result<DoH.Answer<Type>, DoHError>>;
package/dist/resolve.js CHANGED
@@ -1,23 +1,9 @@
1
1
  import { failure, isSuccess, success } from "@sdxc/result";
2
2
  import { canonicalType, parseRecordData, typeName } from "@sdxc/zone-file";
3
- import * as s from "remix/data-schema";
4
- import { NameNotFoundError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
3
+ import { NameNotFoundError, ResponseCodeError, ServerFailureError } from "./errors.js";
4
+ import { queryJson } from "./json.js";
5
5
  import { CLOUDFLARE } from "./resolvers.js";
6
- /** One resource record as the JSON API writes it. */
7
- const WIRE_RECORD_SCHEMA = s.object({
8
- name: s.string(),
9
- type: s.number(),
10
- TTL: s.number(),
11
- data: s.string(),
12
- });
13
- /** The members of the JSON envelope this package reads; Cloudflare and Google both send them. */
14
- const ENVELOPE_SCHEMA = s.object({
15
- Status: s.number(),
16
- TC: s.optional(s.boolean()),
17
- AD: s.optional(s.boolean()),
18
- Answer: s.optional(s.array(WIRE_RECORD_SCHEMA)),
19
- Authority: s.optional(s.array(WIRE_RECORD_SCHEMA)),
20
- });
6
+ import { queryWire } from "./wire.js";
21
7
  /** Folds a name the way every owner name in an answer is reported. */
22
8
  function normalizeName(name) {
23
9
  let lower = name.toLowerCase();
@@ -35,42 +21,45 @@ function normalizeName(name) {
35
21
  * @returns The answer, or the typed reason there is none.
36
22
  * @template Type - The record type, which types the records returned.
37
23
  * @example let answer = await resolve("example.com", "MX");
24
+ * @example let answer = await resolve("example.com", "HTTPS", { resolver: CLOUDFLARE_WIRE });
38
25
  */
39
26
  export async function resolve(name, type, options = {}) {
40
27
  let resolver = options.resolver ?? CLOUDFLARE;
41
28
  let timeoutMs = options.timeoutMs ?? 5000;
42
29
  let asked = canonicalType(type);
43
- let url = new URL(resolver.url);
44
- url.searchParams.set("name", name);
45
- url.searchParams.set("type", /^TYPE\d+$/.test(asked) ? asked.slice(4) : asked);
46
- if (options.dnssec)
47
- url.searchParams.set("do", "1");
48
- if (options.checkingDisabled)
49
- url.searchParams.set("cd", "1");
50
30
  let timeout = AbortSignal.timeout(timeoutMs);
51
31
  let signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
32
+ let deadline = { signal, timeout, timeoutMs };
33
+ let query = {
34
+ name,
35
+ type: asked,
36
+ dnssec: options.dnssec ?? false,
37
+ checkingDisabled: options.checkingDisabled ?? false,
38
+ };
52
39
  let started = performance.now();
53
- let envelope = await fetchEnvelope(url, signal, timeout, timeoutMs);
54
- if (!isSuccess(envelope))
55
- return envelope;
40
+ let message = resolver.format === "wire"
41
+ ? await queryWire(resolver, query, deadline)
42
+ : await queryJson(resolver, query, deadline);
43
+ if (!isSuccess(message))
44
+ return message;
56
45
  let durationMs = Math.round(performance.now() - started);
57
- let { Status: rcode } = envelope.data;
46
+ let { rcode, extendedErrors } = message.data;
58
47
  if (rcode === 3) {
59
- let authenticated = envelope.data.AD ?? false;
60
- return failure(new NameNotFoundError(name, negativeTtl(envelope.data), authenticated));
48
+ let ttl = negativeTtl(message.data);
49
+ return failure(new NameNotFoundError(name, ttl, message.data.authenticated, extendedErrors));
61
50
  }
62
51
  if (rcode === 2)
63
- return failure(new ServerFailureError(name));
52
+ return failure(new ServerFailureError(name, extendedErrors));
64
53
  if (rcode !== 0)
65
- return failure(new ResponseCodeError(name, rcode));
54
+ return failure(new ResponseCodeError(name, rcode, extendedErrors));
66
55
  let records = [];
67
56
  let unparsed = [];
68
57
  let chain = [];
69
- for (let record of envelope.data.Answer ?? []) {
58
+ for (let record of message.data.answer) {
70
59
  let recordType = typeName(record.type);
71
60
  if (recordType !== asked && recordType !== "CNAME")
72
61
  continue;
73
- let base = { name: normalizeName(record.name), ttl: record.TTL };
62
+ let base = { name: record.name, ttl: record.ttl };
74
63
  if (recordType === asked) {
75
64
  let data = parseRecordData(recordType, record.data);
76
65
  if (isSuccess(data))
@@ -92,50 +81,20 @@ export async function resolve(name, type, options = {}) {
92
81
  unparsed,
93
82
  chain,
94
83
  ttl: ttls.length > 0 ? Math.min(...ttls) : null,
95
- authenticated: envelope.data.AD ?? false,
96
- truncated: envelope.data.TC ?? false,
84
+ authenticated: message.data.authenticated,
85
+ truncated: message.data.truncated,
86
+ extendedErrors,
97
87
  durationMs,
98
88
  });
99
89
  }
100
- /**
101
- * Sends the query and validates the envelope, mapping every way the exchange can fail
102
- * without a DNS answer to a `TransportError`.
103
- */
104
- async function fetchEnvelope(url, signal, timeout, timeoutMs) {
105
- let response;
106
- try {
107
- response = await fetch(url, { headers: { Accept: "application/dns-json" }, signal });
108
- }
109
- catch (error) {
110
- let message = timeout.aborted
111
- ? `The DNS query timed out after ${timeoutMs}ms`
112
- : `The DNS query failed: ${error instanceof Error ? error.message : String(error)}`;
113
- return failure(new TransportError(message, null, { cause: error }));
114
- }
115
- if (!response.ok) {
116
- return failure(new TransportError(`The resolver answered HTTP ${response.status}`, response.status));
117
- }
118
- let body;
119
- try {
120
- body = await response.json();
121
- }
122
- catch (error) {
123
- return failure(new TransportError("The resolver's response is not JSON", response.status, { cause: error }));
124
- }
125
- let parsed = s.parseSafe(ENVELOPE_SCHEMA, body);
126
- if (!parsed.success) {
127
- return failure(new TransportError("The resolver's response is not a DNS JSON answer", response.status));
128
- }
129
- return success(parsed.value);
130
- }
131
90
  /**
132
91
  * The negative-caching TTL of an NXDOMAIN (RFC 2308 section 5): the smaller of the SOA
133
92
  * record's own TTL and its `minimum` field, or `null` without a readable SOA.
134
93
  */
135
- function negativeTtl(envelope) {
136
- let soa = (envelope.Authority ?? []).find((record) => typeName(record.type) === "SOA");
94
+ function negativeTtl(message) {
95
+ let soa = message.authority.find((record) => typeName(record.type) === "SOA");
137
96
  if (!soa)
138
97
  return null;
139
98
  let data = parseRecordData("SOA", soa.data);
140
- return isSuccess(data) ? Math.min(soa.TTL, data.data.minimum) : null;
99
+ return isSuccess(data) ? Math.min(soa.ttl, data.data.minimum) : null;
141
100
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The public resolvers that answer the DoH JSON API, ready to pass as `resolver`.
2
+ * The public resolvers, in the JSON API and the RFC 8484 wire format each answers, ready
3
+ * to pass as `resolver`.
3
4
  *
4
5
  * @author [Sergio Xalambrí](https://sergiodxa.com)
5
6
  * @copyright Sergio Xalambrí 2026
@@ -9,3 +10,7 @@ import type { DoH } from "./types.js";
9
10
  export declare const CLOUDFLARE: DoH.Resolver;
10
11
  /** Google Public DNS, which answers JSON at `/resolve` regardless of `Accept`. */
11
12
  export declare const GOOGLE: DoH.Resolver;
13
+ /** Cloudflare's 1.1.1.1 in RFC 8484 wire format, which reports RFC 8914 extended errors. */
14
+ export declare const CLOUDFLARE_WIRE: DoH.Resolver;
15
+ /** Google Public DNS in RFC 8484 wire format, which reports RFC 8914 extended errors. */
16
+ export declare const GOOGLE_WIRE: DoH.Resolver;
package/dist/resolvers.js CHANGED
@@ -5,3 +5,10 @@ export const CLOUDFLARE = {
5
5
  };
6
6
  /** Google Public DNS, which answers JSON at `/resolve` regardless of `Accept`. */
7
7
  export const GOOGLE = { url: "https://dns.google/resolve", format: "json" };
8
+ /** Cloudflare's 1.1.1.1 in RFC 8484 wire format, which reports RFC 8914 extended errors. */
9
+ export const CLOUDFLARE_WIRE = {
10
+ url: "https://cloudflare-dns.com/dns-query",
11
+ format: "wire",
12
+ };
13
+ /** Google Public DNS in RFC 8484 wire format, which reports RFC 8914 extended errors. */
14
+ export const GOOGLE_WIRE = { url: "https://dns.google/dns-query", format: "wire" };
package/dist/types.d.ts CHANGED
@@ -14,12 +14,23 @@ export declare namespace DoH {
14
14
  /** A record type by mnemonic; the typed ones autocomplete, any other name is accepted. */
15
15
  type RecordType = ZoneFile.RecordType;
16
16
  /**
17
- * A DoH endpoint and the encoding it answers in. `format` leaves room for an RFC 8484
18
- * `application/dns-message` resolver as a new value with no change to `resolve`.
17
+ * A DoH endpoint and the encoding it speaks: `"json"` for the `application/dns-json` API
18
+ * Cloudflare and Google answer, `"wire"` for RFC 8484 `application/dns-message`, which
19
+ * any standard DoH server answers and which carries RFC 8914 extended errors.
19
20
  */
20
21
  interface Resolver {
21
22
  url: string;
22
- format: "json";
23
+ format: "json" | "wire";
24
+ }
25
+ /**
26
+ * An RFC 8914 extended DNS error: why a resolver answered as it did, such as `6` (DNSSEC
27
+ * Bogus), `9` (DNSKEY Missing), `18` (Prohibited) or `22` (No Reachable Authority).
28
+ */
29
+ interface ExtendedError {
30
+ /** The INFO-CODE from the IANA Extended DNS Error Codes registry. */
31
+ code: number;
32
+ /** The resolver's EXTRA-TEXT, `null` when it sent none. */
33
+ text: string | null;
23
34
  }
24
35
  /** What every record carries. */
25
36
  interface RecordBase {
@@ -52,6 +63,20 @@ export declare namespace DoH {
52
63
  type SRVRecord = RecordFor<"SRV">;
53
64
  /** The zone's start of authority, found at its apex and in negative answers. */
54
65
  type SOARecord = RecordFor<"SOA">;
66
+ /** A service binding (RFC 9460): where and how to reach the service at `name`. */
67
+ type SVCBRecord = RecordFor<"SVCB">;
68
+ /** The service binding for an HTTPS origin, with its ALPN, port and address hints. */
69
+ type HTTPSRecord = RecordFor<"HTTPS">;
70
+ /** A DANE binding of a TLS certificate or key to the service at `name` (RFC 6698). */
71
+ type TLSARecord = RecordFor<"TLSA">;
72
+ /** A digest of a child zone's key, published at the delegation in the parent (RFC 4034). */
73
+ type DSRecord = RecordFor<"DS">;
74
+ /** The DS record a child asks its parent to publish (RFC 7344). */
75
+ type CDSRecord = RecordFor<"CDS">;
76
+ /** A zone's public signing key, with the key tag DS and RRSIG records name it by. */
77
+ type DNSKEYRecord = RecordFor<"DNSKEY">;
78
+ /** The key a child asks its parent to derive a DS record from (RFC 7344). */
79
+ type CDNSKEYRecord = RecordFor<"CDNSKEY">;
55
80
  /** A record of a type without a typed reading, or one whose data failed to parse, with its raw RDATA. */
56
81
  type UnknownRecord = ZoneFile.UnknownData & RecordBase;
57
82
  /** A successful (NOERROR) answer to one query. */
@@ -71,6 +96,8 @@ export declare namespace DoH {
71
96
  authenticated: boolean;
72
97
  /** The TC flag: the resolver's upstream answer was truncated. */
73
98
  truncated: boolean;
99
+ /** The resolver's RFC 8914 extended errors, such as `3` (Stale Answer); a `json` resolver's are always empty. */
100
+ extendedErrors: ExtendedError[];
74
101
  durationMs: number;
75
102
  }
76
103
  /** Options for one lookup. */
@@ -79,9 +106,9 @@ export declare namespace DoH {
79
106
  * @default CLOUDFLARE
80
107
  */
81
108
  resolver?: Resolver;
82
- /** Sets `do=1`, asking for DNSSEC records alongside the answer. */
109
+ /** Sets the DO bit (`do=1` in JSON), asking for DNSSEC records alongside the answer. */
83
110
  dnssec?: boolean;
84
- /** Sets `cd=1`, asking the resolver to skip DNSSEC validation. */
111
+ /** Sets the CD bit (`cd=1` in JSON), asking the resolver to skip DNSSEC validation. */
85
112
  checkingDisabled?: boolean;
86
113
  signal?: AbortSignal;
87
114
  /**
package/dist/wire.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * RFC 8484 DNS over HTTPS in wire format (`application/dns-message`): the query is an
3
+ * RFC 1035 message with an EDNS(0) OPT record, and the response is read back into the shape
4
+ * `resolve` works from, RFC 8914 extended errors and extended RCODEs included.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { Deadline, Message, Query } from "./exchange.js";
11
+ import type { DoH } from "./types.js";
12
+ import { TransportError } from "./errors.js";
13
+ /**
14
+ * The query message for one question: ID 0, as RFC 8484 recommends for cacheable GETs; RD
15
+ * and AD set so the resolver recurses and reports validation; CD and the OPT record's DO bit
16
+ * set from the flags; `null` when the name does not fit a question.
17
+ *
18
+ * @param query - The question and flags.
19
+ */
20
+ export declare function encodeQuery(query: Query): Uint8Array | null;
21
+ /**
22
+ * Reads a response message: the header flags, the answer and authority records with their
23
+ * data as RFC 3597 generic data, and the OPT record's extended RCODE bits and extended
24
+ * errors; `null` when the octets are not a well-formed response.
25
+ *
26
+ * @param octets - The response body.
27
+ */
28
+ export declare function decodeResponse(octets: Uint8Array): Message | null;
29
+ /**
30
+ * Asks a wire-format resolver one question with an RFC 8484 GET, which shared caches can
31
+ * store under the `dns` parameter.
32
+ *
33
+ * @param resolver - The endpoint, which takes the query in its `dns` parameter.
34
+ * @param query - The question and flags.
35
+ * @param deadline - Aborts the request.
36
+ */
37
+ export declare function queryWire(resolver: DoH.Resolver, query: Query, deadline: Deadline): Promise<Result<Message, TransportError>>;
package/dist/wire.js ADDED
@@ -0,0 +1,252 @@
1
+ import { failure, isFailure, isSuccess, success } from "@sdxc/result";
2
+ import { parseRecordData, typeCode } from "@sdxc/zone-file";
3
+ import { TransportError } from "./errors.js";
4
+ import { exchange } from "./exchange.js";
5
+ /** The media type RFC 8484 sends and answers in. */
6
+ const MEDIA_TYPE = "application/dns-message";
7
+ /** The EDNS(0) UDP payload size the query advertises, the DNS Flag Day 2020 default. */
8
+ const PAYLOAD_SIZE = 1232;
9
+ /** The OPT pseudo-record type, which carries EDNS(0) flags, extended RCODE bits and options. */
10
+ const OPT = 41;
11
+ /** The EDNS option code of an RFC 8914 extended DNS error. */
12
+ const EXTENDED_ERROR_OPTION = 15;
13
+ /**
14
+ * The RDATA layout of the types whose names a server may compress (RFC 3597 section 4):
15
+ * NS, CNAME, SOA, PTR and MX, each field a name or a run of octets. Expanding those names
16
+ * turns the RDATA into the generic data `parseRecordData` reads.
17
+ */
18
+ const COMPRESSIBLE_NAMES = {
19
+ 2: ["name"],
20
+ 5: ["name"],
21
+ 6: ["name", "name", 20],
22
+ 12: ["name"],
23
+ 15: [2, "name"],
24
+ };
25
+ /** How many compression pointers one name may follow, which bounds a looping message. */
26
+ const MAX_POINTERS = 64;
27
+ /** Encodes the text of names and option text into octets. */
28
+ const ENCODER = new TextEncoder();
29
+ /** Decodes an extended error's EXTRA-TEXT, which RFC 8914 makes UTF-8. */
30
+ const DECODER = new TextDecoder();
31
+ /**
32
+ * The labels of a name as written: an optional trailing dot, each label 1 to 63 octets and
33
+ * the whole name at most 255; `null` for a name that does not fit a question.
34
+ */
35
+ function encodeName(name) {
36
+ let trimmed = name.endsWith(".") ? name.slice(0, -1) : name;
37
+ let labels = trimmed === "" ? [] : trimmed.split(".").map((label) => ENCODER.encode(label));
38
+ if (labels.some((label) => label.length === 0 || label.length > 63))
39
+ return null;
40
+ let octets = Uint8Array.from([...labels.flatMap((label) => [label.length, ...label]), 0]);
41
+ return octets.length <= 255 ? octets : null;
42
+ }
43
+ /**
44
+ * The query message for one question: ID 0, as RFC 8484 recommends for cacheable GETs; RD
45
+ * and AD set so the resolver recurses and reports validation; CD and the OPT record's DO bit
46
+ * set from the flags; `null` when the name does not fit a question.
47
+ *
48
+ * @param query - The question and flags.
49
+ */
50
+ export function encodeQuery(query) {
51
+ let name = encodeName(query.name);
52
+ let type = typeCode(query.type);
53
+ if (!name || type === null)
54
+ return null;
55
+ let flags = 0x0100 | 0x0020 | (query.checkingDisabled ? 0x0010 : 0);
56
+ let header = [0, 0, flags >> 8, flags & 0xff, 0, 1, 0, 0, 0, 0, 0, 1];
57
+ let question = [type >> 8, type & 0xff, 0, 1];
58
+ let dnssec = query.dnssec ? 0x80 : 0;
59
+ let opt = [0, OPT >> 8, OPT, PAYLOAD_SIZE >> 8, PAYLOAD_SIZE & 0xff, 0, 0, dnssec, 0, 0, 0];
60
+ return Uint8Array.from([...header, ...name, ...question, ...opt]);
61
+ }
62
+ /** Octets as unpadded base64url, the encoding of RFC 8484's `dns` query parameter. */
63
+ function base64Url(octets) {
64
+ let base64 = btoa(Array.from(octets, (octet) => String.fromCharCode(octet)).join(""));
65
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
66
+ }
67
+ /**
68
+ * Prints a name's labels through `@sdxc/zone-file`'s codec, as the target of RFC 3597
69
+ * generic CNAME data, so owner names read here match the names inside record data exactly.
70
+ */
71
+ function printName(labels) {
72
+ let wire = [...labels.flatMap((label) => [label.length, ...label]), 0];
73
+ let parsed = parseRecordData("CNAME", generic(Uint8Array.from(wire)));
74
+ return isSuccess(parsed) ? parsed.data.target : null;
75
+ }
76
+ /** Octets as RFC 3597 generic data, which `parseRecordData` reads for any type. */
77
+ function generic(octets) {
78
+ let hex = Array.from(octets, (octet) => octet.toString(16).padStart(2, "0")).join("");
79
+ return hex.length === 0 ? "\\# 0" : `\\# ${octets.length} ${hex}`;
80
+ }
81
+ /**
82
+ * Reads a DNS message front to back. Names follow compression pointers anywhere in the
83
+ * message. A read past the end, a pointer loop or an unknown label type sets `failed` and
84
+ * reads as zeros, so a caller checks once after reading instead of after every field.
85
+ */
86
+ class MessageReader {
87
+ #octets;
88
+ offset = 0;
89
+ failed = false;
90
+ /** @param octets - The whole message. */
91
+ constructor(octets) {
92
+ this.#octets = octets;
93
+ }
94
+ /** The next `length` octets, zeros once the message is exhausted. */
95
+ bytes(length) {
96
+ if (this.failed || this.offset + length > this.#octets.length) {
97
+ this.failed = true;
98
+ return new Uint8Array(length);
99
+ }
100
+ let bytes = this.#octets.subarray(this.offset, this.offset + length);
101
+ this.offset += length;
102
+ return bytes;
103
+ }
104
+ /** A big-endian unsigned integer of `size` octets. */
105
+ uint(size) {
106
+ return this.bytes(size).reduce((total, byte) => total * 256 + byte, 0);
107
+ }
108
+ /** A name's labels, following compression pointers, leaving the offset after the name as written. */
109
+ labels() {
110
+ let labels = [];
111
+ let resume = null;
112
+ let pointers = 0;
113
+ while (!this.failed) {
114
+ let length = this.uint(1);
115
+ if (length === 0)
116
+ break;
117
+ if ((length & 0xc0) === 0xc0) {
118
+ let target = ((length & 0x3f) << 8) | this.uint(1);
119
+ pointers += 1;
120
+ if (pointers > MAX_POINTERS)
121
+ this.failed = true;
122
+ resume ??= this.offset;
123
+ this.offset = target;
124
+ }
125
+ else if (length > 63) {
126
+ this.failed = true;
127
+ }
128
+ else {
129
+ labels.push(this.bytes(length));
130
+ }
131
+ }
132
+ if (resume !== null)
133
+ this.offset = resume;
134
+ return labels;
135
+ }
136
+ }
137
+ /** A name's labels as uncompressed wire octets. */
138
+ function wireName(labels) {
139
+ return [...labels.flatMap((label) => [label.length, ...label]), 0];
140
+ }
141
+ /**
142
+ * A record's RDATA with any compressed names expanded, read in place from the message so
143
+ * pointers resolve against it; a layout that overruns the RDATA length fails the reader.
144
+ */
145
+ function expandRdata(reader, type, length) {
146
+ let layout = COMPRESSIBLE_NAMES[type];
147
+ if (!layout)
148
+ return reader.bytes(length);
149
+ let end = reader.offset + length;
150
+ let octets = [];
151
+ for (let field of layout)
152
+ octets.push(...(field === "name" ? wireName(reader.labels()) : reader.bytes(field)));
153
+ if (reader.offset !== end)
154
+ reader.failed = true;
155
+ return Uint8Array.from(octets);
156
+ }
157
+ /** The RFC 8914 extended errors among an OPT record's options, trailing NULs trimmed from the text. */
158
+ function readExtendedErrors(options) {
159
+ let reader = new MessageReader(options);
160
+ let errors = [];
161
+ while (reader.offset < options.length && !reader.failed) {
162
+ let code = reader.uint(2);
163
+ let data = reader.bytes(reader.uint(2));
164
+ if (code !== EXTENDED_ERROR_OPTION || data.length < 2 || reader.failed)
165
+ continue;
166
+ let text = DECODER.decode(data.subarray(2)).replace(/\0+$/, "");
167
+ errors.push({ code: ((data[0] ?? 0) << 8) | (data[1] ?? 0), text: text || null });
168
+ }
169
+ return errors;
170
+ }
171
+ /**
172
+ * Reads a response message: the header flags, the answer and authority records with their
173
+ * data as RFC 3597 generic data, and the OPT record's extended RCODE bits and extended
174
+ * errors; `null` when the octets are not a well-formed response.
175
+ *
176
+ * @param octets - The response body.
177
+ */
178
+ export function decodeResponse(octets) {
179
+ let reader = new MessageReader(octets);
180
+ reader.bytes(2);
181
+ let flags = reader.uint(2);
182
+ let questions = reader.uint(2);
183
+ let answers = reader.uint(2);
184
+ let authorities = reader.uint(2);
185
+ let additionals = reader.uint(2);
186
+ if ((flags & 0x8000) === 0)
187
+ return null;
188
+ for (let index = 0; index < questions && !reader.failed; index++) {
189
+ reader.labels();
190
+ reader.bytes(4);
191
+ }
192
+ let answer = [];
193
+ let authority = [];
194
+ let extendedRcode = 0;
195
+ let extendedErrors = [];
196
+ for (let index = 0; index < answers + authorities + additionals && !reader.failed; index++) {
197
+ let labels = reader.labels();
198
+ let type = reader.uint(2);
199
+ reader.bytes(2);
200
+ let ttl = reader.uint(4);
201
+ let length = reader.uint(2);
202
+ if (type === OPT) {
203
+ extendedRcode = ttl >>> 24;
204
+ extendedErrors = readExtendedErrors(reader.bytes(length));
205
+ continue;
206
+ }
207
+ let data = generic(expandRdata(reader, type, length));
208
+ let name = printName(labels);
209
+ if (name === null)
210
+ reader.failed = true;
211
+ else if (index < answers)
212
+ answer.push({ name, type, ttl, data });
213
+ else if (index < answers + authorities)
214
+ authority.push({ name, type, ttl, data });
215
+ }
216
+ if (reader.failed)
217
+ return null;
218
+ return {
219
+ rcode: (extendedRcode << 4) | (flags & 0x000f),
220
+ authenticated: (flags & 0x0020) !== 0,
221
+ truncated: (flags & 0x0200) !== 0,
222
+ answer,
223
+ authority,
224
+ extendedErrors,
225
+ };
226
+ }
227
+ /**
228
+ * Asks a wire-format resolver one question with an RFC 8484 GET, which shared caches can
229
+ * store under the `dns` parameter.
230
+ *
231
+ * @param resolver - The endpoint, which takes the query in its `dns` parameter.
232
+ * @param query - The question and flags.
233
+ * @param deadline - Aborts the request.
234
+ */
235
+ export async function queryWire(resolver, query, deadline) {
236
+ let message = encodeQuery(query);
237
+ if (!message) {
238
+ return failure(new TransportError(`${query.name} cannot be sent as a DNS question`, null));
239
+ }
240
+ let url = new URL(resolver.url);
241
+ url.searchParams.set("dns", base64Url(message));
242
+ let response = await exchange(url, MEDIA_TYPE, deadline, async (body) => {
243
+ return new Uint8Array(await body.arrayBuffer());
244
+ });
245
+ if (isFailure(response))
246
+ return response;
247
+ let decoded = decodeResponse(response.data.body);
248
+ if (!decoded) {
249
+ return failure(new TransportError("The resolver's response is not a DNS message", response.data.status));
250
+ }
251
+ return success(decoded);
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/doh",
3
- "version": "2026.10.16",
3
+ "version": "2026.10.25",
4
4
  "description": "Typed DNS over HTTPS lookups",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,12 +10,12 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@sdxc/result": "2026.10.13",
13
- "@sdxc/zone-file": "2026.10.13"
13
+ "@sdxc/zone-file": "2026.10.25"
14
14
  },
15
15
  "peerDependencies": {
16
- "remix": "3.0.0"
16
+ "remix": "3.1.0"
17
17
  },
18
- "gitHead": "1e547d8431568f5ed9bdb079ca3046c936240fde",
18
+ "gitHead": "1440b969c0a3e3808dfcee3f17d2ef43915f32a8",
19
19
  "publishConfig": {
20
20
  "access": "public"
21
21
  },