spiderlint 0.0.0 → 0.143.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 (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +233 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +358 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +56 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +16 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +353 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
@@ -0,0 +1,153 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { log } from "../logger.js";
5
+ // Directory names kept apart per level; later names share one wildcard child.
6
+ const FANOUT = 32;
7
+ const WILDCARD = "*";
8
+ // Pooled candidates in arrival order.
9
+ class Bucket {
10
+ #items = [];
11
+ #head = 0;
12
+ get size() {
13
+ return this.#items.length - this.#head;
14
+ }
15
+ push(candidate) {
16
+ this.#items.push(candidate);
17
+ }
18
+ // The oldest candidate; the array is compacted once half of it is spent.
19
+ shift() {
20
+ const candidate = this.#items[this.#head];
21
+ this.#head += 1;
22
+ if (this.#head >= 1024 && this.#head * 2 >= this.#items.length) {
23
+ this.#items = this.#items.slice(this.#head);
24
+ this.#head = 0;
25
+ }
26
+ return candidate;
27
+ }
28
+ }
29
+ // One directory of the URL tree: its own pages, its subdirectories, and how much of the budget each took.
30
+ class Directory {
31
+ children = new Map();
32
+ leaves = new Bucket();
33
+ // Pages scheduled from this subtree, and from its own pages alone.
34
+ taken = 0;
35
+ leafTaken = 0;
36
+ // Distinct pages this directory itself has held.
37
+ leafCount = 0;
38
+ // Candidates still pooled in this subtree.
39
+ pooled = 0;
40
+ }
41
+ // The URL without its fragment, which names the same page.
42
+ function keyOf(href) {
43
+ const url = new URL(href);
44
+ url.hash = "";
45
+ return url.href;
46
+ }
47
+ // Candidates pooled by directory; each pick descends to the least-scheduled child, so a budget spreads over the site’s sections before it deepens one.
48
+ export class Spread {
49
+ #root = new Directory();
50
+ #known = new Set();
51
+ #pooled = new Map();
52
+ #limit;
53
+ // `limit` caps one directory’s pooled pages: a budget never takes more than that from it.
54
+ constructor(limit) {
55
+ this.#limit = limit;
56
+ }
57
+ // The directories `href` sits under, host first, created on the way.
58
+ #walk(href) {
59
+ const url = new URL(href);
60
+ const names = [url.host, ...url.pathname.split("/").filter(Boolean).slice(0, -1)];
61
+ const path = [];
62
+ let node = this.#root;
63
+ for (const name of names) {
64
+ const key = node.children.has(name) || node.children.size < FANOUT ? name : WILDCARD;
65
+ const next = node.children.get(key) ?? new Directory();
66
+ node.children.set(key, next);
67
+ path.push(next);
68
+ node = next;
69
+ }
70
+ return path;
71
+ }
72
+ // The next candidate: at each level the child, or the directory’s own pages, with the smallest share of the budget so far.
73
+ #pick() {
74
+ const path = [];
75
+ let node = this.#root;
76
+ for (;;) {
77
+ let next;
78
+ let least = node.leaves.size > 0 ? node.leafTaken / Math.min(node.leafCount, FANOUT) : Infinity;
79
+ for (const child of node.children.values()) {
80
+ if (child.pooled === 0 || child.taken >= least)
81
+ continue;
82
+ next = child;
83
+ least = child.taken;
84
+ }
85
+ if (!next)
86
+ break;
87
+ path.push(next);
88
+ node = next;
89
+ }
90
+ const candidate = node.leaves.shift();
91
+ this.#pooled.delete(keyOf(candidate.url));
92
+ node.leafTaken += 1;
93
+ for (const directory of path) {
94
+ directory.taken += 1;
95
+ directory.pooled -= 1;
96
+ }
97
+ this.#root.pooled -= 1;
98
+ return candidate;
99
+ }
100
+ get size() {
101
+ return this.#root.pooled;
102
+ }
103
+ // Counts a page scheduled elsewhere, so the pool favours what it has not covered; false for one already known.
104
+ note(href) {
105
+ const key = keyOf(href);
106
+ if (this.#known.has(key))
107
+ return false;
108
+ this.#known.add(key);
109
+ const path = this.#walk(href);
110
+ for (const directory of path)
111
+ directory.taken += 1;
112
+ const holder = path.at(-1);
113
+ holder.leafTaken += 1;
114
+ holder.leafCount += 1;
115
+ return true;
116
+ }
117
+ // Pools a candidate; false for one already known or past its directory’s limit.
118
+ add(candidate) {
119
+ const key = keyOf(candidate.url);
120
+ if (this.#known.has(key))
121
+ return false;
122
+ const path = this.#walk(candidate.url);
123
+ const holder = path.at(-1);
124
+ if (holder.leaves.size >= this.#limit) {
125
+ log.debug({ url: candidate.url, limit: this.#limit }, "candidate dropped, directory full");
126
+ return false;
127
+ }
128
+ this.#known.add(key);
129
+ holder.leaves.push(candidate);
130
+ this.#pooled.set(key, candidate);
131
+ holder.leafCount += 1;
132
+ for (const directory of path)
133
+ directory.pooled += 1;
134
+ this.#root.pooled += 1;
135
+ return true;
136
+ }
137
+ // Gives a pooled candidate that no link has reached the depth of the page linking to it; true when it did.
138
+ relink(url, crawlDepth) {
139
+ const pooled = this.#pooled.get(keyOf(url));
140
+ if (!pooled || pooled.crawlDepth > 0)
141
+ return false;
142
+ pooled.crawlDepth = crawlDepth;
143
+ return true;
144
+ }
145
+ // Up to `count` candidates, each from the least-scheduled directory at every level.
146
+ take(count) {
147
+ const taken = [];
148
+ while (taken.length < count && this.#root.pooled > 0)
149
+ taken.push(this.#pick());
150
+ log.debug({ requested: count, taken: taken.length, pooled: this.#root.pooled }, "candidates taken");
151
+ return taken;
152
+ }
153
+ }
@@ -0,0 +1,194 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { randomInt } from "node:crypto";
5
+ import { createSocket } from "node:dgram";
6
+ import { readFileSync } from "node:fs";
7
+ import { connect, isIP } from "node:net";
8
+ import { setTimeout as sleep } from "node:timers/promises";
9
+ import dnsPacket, {} from "dns-packet";
10
+ import { ConfigError } from "../config/index.js";
11
+ import { log } from "../logger.js";
12
+ import { reason } from "./fetch.js";
13
+ const ATTEMPTS = 2;
14
+ const TIMEOUT_MS = 3000;
15
+ const PAYLOAD = 1232;
16
+ // A client refusing every query, for a proxied run whose queries would bypass the proxy.
17
+ export const PROXIED_DNS = {
18
+ canQueryDirectly: false,
19
+ query: async (name) => {
20
+ throw new Error(`DNS query for ${name} would bypass the proxy`);
21
+ },
22
+ validating: async () => false,
23
+ };
24
+ // `address[:port]`, an IPv6 address bracketed when it carries a port.
25
+ function server(entry) {
26
+ const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(entry);
27
+ const [address, port] = bracketed ? [bracketed[1], bracketed[2]] : isIP(entry) === 6 ? [entry, undefined] : entry.split(":");
28
+ const number = port === undefined ? 53 : Number(port);
29
+ if (isIP(address.split("%", 1)[0]) === 0 || !Number.isSafeInteger(number) || number < 1 || number > 65_535)
30
+ throw new ConfigError(`resolver: invalid server ${entry} (expected address[:port])`);
31
+ return { address, port: number };
32
+ }
33
+ // `system`, or a comma list of `address[:port]`; anything else is a config error.
34
+ export function parseResolver(raw) {
35
+ const value = raw.trim();
36
+ if (value !== "system")
37
+ for (const entry of value.split(","))
38
+ server(entry.trim());
39
+ return value;
40
+ }
41
+ // The `nameserver` lines of `/etc/resolv.conf`.
42
+ function systemServers() {
43
+ let text = "";
44
+ try {
45
+ text = readFileSync("/etc/resolv.conf", "utf8");
46
+ }
47
+ catch (error) {
48
+ log.warn({ error: reason(error) }, "resolv.conf unreadable");
49
+ }
50
+ const servers = text
51
+ .matchAll(/^\s*nameserver\s+(\S+)/gm)
52
+ .map((match) => ({ address: match[1], port: 53 }))
53
+ .toArray();
54
+ log.debug({ servers: servers.map((entry) => entry.address) }, "system resolvers read");
55
+ return servers;
56
+ }
57
+ // The servers a resolver setting names.
58
+ export function servers(resolver) {
59
+ return resolver === "system" ? systemServers() : resolver.split(",").map((entry) => server(entry.trim()));
60
+ }
61
+ function label({ address, port }) {
62
+ return isIP(address.split("%", 1)[0]) === 6 ? `[${address}]:${port}` : `${address}:${port}`;
63
+ }
64
+ // One query over UDP, answered only by a reply carrying its ID.
65
+ function overUdp(target, message, id, signal) {
66
+ return new Promise((resolve, reject) => {
67
+ const socket = createSocket(isIP(target.address.split("%", 1)[0]) === 6 ? "udp6" : "udp4");
68
+ const done = (error, reply) => {
69
+ socket.close();
70
+ signal.removeEventListener("abort", abort);
71
+ if (error)
72
+ reject(error);
73
+ else
74
+ resolve(reply);
75
+ };
76
+ const abort = () => done(new Error(`no answer from ${label(target)} in ${TIMEOUT_MS} ms`));
77
+ signal.addEventListener("abort", abort, { once: true });
78
+ socket.on("error", (error) => done(error));
79
+ socket.on("message", (reply) => {
80
+ if (reply.length >= 2 && reply.readUInt16BE(0) === id)
81
+ done(undefined, reply);
82
+ });
83
+ socket.send(message, target.port, target.address);
84
+ });
85
+ }
86
+ // One query over TCP, each message prefixed by its length.
87
+ function overTcp(target, message, signal) {
88
+ return new Promise((resolve, reject) => {
89
+ const socket = connect({ host: target.address, port: target.port, signal });
90
+ const chunks = [];
91
+ const prefix = Buffer.alloc(2);
92
+ prefix.writeUInt16BE(message.length);
93
+ socket.on("connect", () => socket.end(Buffer.concat([prefix, message])));
94
+ socket.on("data", (chunk) => {
95
+ chunks.push(chunk);
96
+ });
97
+ socket.on("error", reject);
98
+ socket.on("end", () => {
99
+ const reply = Buffer.concat(chunks);
100
+ if (reply.length < 2 || reply.length < 2 + reply.readUInt16BE(0))
101
+ reject(new Error(`short TCP answer from ${label(target)}`));
102
+ else
103
+ resolve(reply.subarray(2, 2 + reply.readUInt16BE(0)));
104
+ });
105
+ });
106
+ }
107
+ // Encodes one question with `DO` set and `RD` unless the server is asked directly.
108
+ function encode(name, type, options) {
109
+ const id = randomInt(65_536);
110
+ const flags = (options.server === undefined || options.recursion ? dnsPacket.RECURSION_DESIRED : 0) | dnsPacket.AUTHENTIC_DATA | (options.checkingDisabled ? dnsPacket.CHECKING_DISABLED : 0);
111
+ const message = dnsPacket.encode({ type: "query", id, flags, questions: [{ type: type, name, class: "IN" }], additionals: [{ type: "OPT", name: ".", udpPayloadSize: PAYLOAD, flags: dnsPacket.DNSSEC_OK }] });
112
+ return { id, message };
113
+ }
114
+ // One exchange with one server: UDP, then TCP when the answer is truncated.
115
+ async function exchange(target, name, type, options, outer) {
116
+ const { id, message } = encode(name, type, options);
117
+ const signal = AbortSignal.any([outer, AbortSignal.timeout(TIMEOUT_MS)]);
118
+ const reply = await overUdp(target, message, id, signal);
119
+ if ((reply.readUInt16BE(2) & 0x02_00) === 0)
120
+ return reply;
121
+ log.debug({ name, type, server: label(target) }, "dns answer truncated, retrying over TCP");
122
+ return overTcp(target, message, signal);
123
+ }
124
+ // Decodes the wire answer the extractors read.
125
+ function decode(target, wire) {
126
+ const packet = dnsPacket.decode(wire);
127
+ return { server: target, rcode: packet.rcode ?? "NOERROR", aa: packet.flag_aa ?? false, ad: packet.flag_ad ?? false, ra: packet.flag_ra ?? false, answers: packet.answers ?? [], authorities: packet.authorities ?? [] };
128
+ }
129
+ // The smallest TTL in an answer, floored at `floor` seconds.
130
+ function lifetime(reply, floor) {
131
+ const ttls = [...reply.answers, ...reply.authorities].flatMap((record) => ("ttl" in record && record.ttl !== undefined ? [record.ttl] : []));
132
+ return ttls.length === 0 ? floor : Math.max(floor, Math.min(...ttls));
133
+ }
134
+ // Whether the resolver sets `AD` on the signed root SOA; warns when it does not.
135
+ async function isValidating(client, resolver) {
136
+ try {
137
+ const { ad } = await client.query(".", "SOA");
138
+ log.debug({ resolver, validating: ad }, "resolver validation checked");
139
+ if (!ad)
140
+ log.warn({ resolver }, `resolver ${resolver} does not validate DNSSEC; dns/dnssec-bogus skipped`);
141
+ return ad;
142
+ }
143
+ catch (error) {
144
+ log.warn({ resolver, error: reason(error) }, `resolver ${resolver} unreachable, dns/dnssec-bogus skipped:`);
145
+ return false;
146
+ }
147
+ }
148
+ // A resolver over the configured servers, caching every answer for its record TTL.
149
+ export function dnsClient(resolver, bucket, canQueryDirectly, directPort = 53) {
150
+ const configured = servers(resolver);
151
+ let validation;
152
+ const client = {
153
+ canQueryDirectly,
154
+ async query(name, type, options = {}) {
155
+ if (!canQueryDirectly && options.server !== undefined)
156
+ throw new Error(`direct query to ${options.server} refused`);
157
+ const targets = options.server === undefined ? configured : [{ address: options.server, port: directPort }];
158
+ if (targets.length === 0)
159
+ throw new Error("no resolver configured");
160
+ const signal = options.signal ?? new AbortController().signal;
161
+ signal.throwIfAborted();
162
+ const key = `${targets.map((target) => label(target)).join(",")}\t${name}\t${type}\t${options.checkingDisabled ? "cd" : ""}${options.recursion ? "rd" : ""}`;
163
+ const entry = await bucket.get(key);
164
+ if (entry && Date.parse(entry.value.expires) > Date.now())
165
+ return decode(targets[0] ? label(targets[0]) : "", Buffer.from(entry.value.packet, "base64"));
166
+ let failure;
167
+ for (const target of targets) {
168
+ for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
169
+ try {
170
+ const wire = await exchange(target, name, type, options, signal);
171
+ const reply = decode(label(target), wire);
172
+ log.debug({ name, type, server: label(target), rcode: reply.rcode, answers: reply.answers.length, ad: reply.ad, attempt }, "dns answered");
173
+ await bucket.set(key, { expires: new Date(Date.now() + lifetime(reply, bucket.ttlSeconds) * 1000).toISOString(), packet: wire.toString("base64") });
174
+ return reply;
175
+ }
176
+ catch (error) {
177
+ failure = error;
178
+ log.debug({ name, type, server: label(target), attempt, error: reason(error) }, "dns query failed");
179
+ if (signal.aborted)
180
+ throw error;
181
+ await sleep(100 * 2 ** attempt + Math.random() * 100);
182
+ }
183
+ }
184
+ }
185
+ throw failure instanceof Error ? failure : new Error(String(failure));
186
+ },
187
+ // The root zone is signed, so a validating resolver sets `AD` on its SOA; warns once when it does not.
188
+ validating() {
189
+ validation ??= isValidating(client, resolver);
190
+ return validation;
191
+ },
192
+ };
193
+ return client;
194
+ }
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { agentHeaders } from "../agent.js";
6
+ import { log } from "../logger.js";
7
+ import { PrivateAddress } from "./guard.js";
8
+ import { guardedFetch, pace, patient } from "./network.js";
9
+ const ATTEMPTS = 3;
10
+ const TIMEOUT_MS = 30_000;
11
+ const RETRY_STATUS = new Set([429, 503]);
12
+ // Attempts a final answer of `status` cost; 0 is a network failure.
13
+ export function attemptsFor(status) {
14
+ return status === 0 || RETRY_STATUS.has(status) ? ATTEMPTS : 1;
15
+ }
16
+ // Exponential backoff with jitter; a Retry-After in seconds wins, capped at the timeout.
17
+ export function delay(attempt, retryAfter) {
18
+ const seconds = Number(retryAfter ?? NaN);
19
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds * 1000, TIMEOUT_MS) : 500 * 2 ** attempt + Math.random() * 250;
20
+ }
21
+ // The error undici raises for a short body, a duplicate Content-Length, or one beside chunked.
22
+ export const MISMATCH = "Response body length does not match content-length header";
23
+ // The innermost cause of a fetch failure, which names the socket error rather than "fetch failed".
24
+ export function reason(error) {
25
+ return error instanceof Error ? (error.cause instanceof Error ? error.cause.message : error.message) : String(error);
26
+ }
27
+ // One request with timeout and the spiderlint user agent; network errors, 429 and 503 retry, the last failure or a refused address throws.
28
+ export async function fetchRetrying(url, consume, headers = {}, method = "GET") {
29
+ for (let attempt = 0;; attempt += 1) {
30
+ const started = performance.now();
31
+ const isLast = attempt === ATTEMPTS - 1;
32
+ try {
33
+ await pace();
34
+ const response = await guardedFetch(url, { method, headers: { ...headers, ...agentHeaders() }, signal: AbortSignal.timeout(patient(TIMEOUT_MS)) });
35
+ const value = await consume(response);
36
+ log.debug({ url, status: response.status, attempt }, "fetched");
37
+ if (isLast || !RETRY_STATUS.has(response.status))
38
+ return { response, value, ms: Math.round(performance.now() - started) };
39
+ await sleep(delay(attempt, response.headers.get("retry-after") ?? undefined));
40
+ }
41
+ catch (error) {
42
+ log.debug({ url, attempt, error: reason(error) }, "fetch failed");
43
+ if (isLast || error instanceof PrivateAddress)
44
+ throw error;
45
+ await sleep(delay(attempt));
46
+ }
47
+ }
48
+ }