@hraness/kb 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,950 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import {
3
+ Agent as HttpAgent,
4
+ request as requestHttp,
5
+ type ClientRequest,
6
+ type IncomingHttpHeaders,
7
+ type IncomingMessage,
8
+ type RequestOptions,
9
+ } from "node:http";
10
+ import {
11
+ Agent as HttpsAgent,
12
+ request as requestHttps,
13
+ } from "node:https";
14
+ import {
15
+ isIP,
16
+ type LookupFunction,
17
+ type Socket,
18
+ } from "node:net";
19
+ import { networkInterfaces } from "node:os";
20
+ import type { SecureContextOptions } from "node:tls";
21
+
22
+ import { BoundedByteBuffer } from "./bounded-byte-buffer.js";
23
+
24
+ export type FetchFailureCode =
25
+ | "invalid-url"
26
+ | "private-network"
27
+ | "dns"
28
+ | "timeout"
29
+ | "http"
30
+ | "redirect"
31
+ | "too-large"
32
+ | "network";
33
+
34
+ export class FetchFailure extends Error {
35
+ readonly code: FetchFailureCode;
36
+ readonly status: number | null;
37
+
38
+ constructor(code: FetchFailureCode, message: string, options?: ErrorOptions & { readonly status?: number }) {
39
+ super(message, options);
40
+ this.name = "FetchFailure";
41
+ this.code = code;
42
+ this.status = options?.status ?? null;
43
+ }
44
+ }
45
+
46
+ export type SafeFetchOptions = {
47
+ readonly timeoutMs: number;
48
+ readonly maxBytes: number;
49
+ readonly allowPrivateNetwork: boolean;
50
+ readonly userAgent: string;
51
+ readonly referer?: string;
52
+ readonly cookieHeader?: string;
53
+ readonly accept?: string;
54
+ readonly retries?: number;
55
+ readonly maxRedirects?: number;
56
+ /** Return one bounded 3xx response without following its Location header. */
57
+ readonly redirect?: "follow" | "manual";
58
+ /**
59
+ * Non-success response statuses that the caller can interpret without
60
+ * weakening redirect handling. Only bounded 4xx/5xx responses are eligible.
61
+ */
62
+ readonly acceptStatuses?: readonly number[];
63
+ /** Exact origins to which a followed redirect may move. */
64
+ readonly allowedRedirectOrigins?: readonly string[];
65
+ };
66
+
67
+ export type SafeFetchResult = {
68
+ readonly bytes: Uint8Array;
69
+ readonly finalUrl: URL;
70
+ readonly status: number;
71
+ readonly contentType: string | null;
72
+ readonly etag: string | null;
73
+ readonly lastModified: string | null;
74
+ readonly location?: string | null;
75
+ };
76
+
77
+ export type ResolvedNetworkAddress = {
78
+ readonly address: string;
79
+ readonly family: 4 | 6;
80
+ };
81
+
82
+ export type NetworkResolver = (hostname: string) => Promise<readonly ResolvedNetworkAddress[]>;
83
+
84
+ export type LocalNetworkAddressProvider = () => readonly string[];
85
+
86
+ export type PinnedNetworkRequest = {
87
+ readonly url: URL;
88
+ readonly address: ResolvedNetworkAddress;
89
+ readonly method: string;
90
+ readonly headers: Headers;
91
+ readonly body: Uint8Array | null;
92
+ readonly signal: AbortSignal;
93
+ };
94
+
95
+ export type PinnedNetworkResponse = {
96
+ readonly status: number;
97
+ readonly headers: Headers;
98
+ readonly body: AsyncIterable<unknown> | null;
99
+ readonly cancel: () => void;
100
+ };
101
+
102
+ export type NetworkTransport = (request: PinnedNetworkRequest) => Promise<PinnedNetworkResponse>;
103
+
104
+ export type PinnedNetworkConnectionPool = {
105
+ readonly request: NetworkTransport;
106
+ readonly close: () => void;
107
+ };
108
+
109
+ export type PinnedNetworkConnectionPoolOptions = {
110
+ /**
111
+ * Additional certificate authorities for an isolated caller scope.
112
+ * Production callers normally use the platform trust store; this seam keeps
113
+ * local TLS integration tests fully verified without disabling validation.
114
+ */
115
+ readonly certificateAuthorities?: SecureContextOptions["ca"];
116
+ };
117
+
118
+ export type SafeFetchDependencies = {
119
+ readonly resolveHostname: NetworkResolver;
120
+ readonly transport: NetworkTransport;
121
+ /** Test seam. Production callers must use the system interface snapshot. */
122
+ readonly getLocalNetworkAddresses: LocalNetworkAddressProvider;
123
+ };
124
+
125
+ export type SafeNetworkTargetOptions = {
126
+ readonly allowPrivateNetwork: boolean;
127
+ readonly timeoutMs?: number;
128
+ readonly resolveHostname?: NetworkResolver;
129
+ /** Test seam. Production callers must leave this unset. */
130
+ readonly getLocalNetworkAddresses?: LocalNetworkAddressProvider;
131
+ };
132
+
133
+ const privateHostnameSuffixes = [".localhost", ".local", ".internal", ".home", ".lan"];
134
+
135
+ function normalizeHostname(hostname: string): string {
136
+ const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
137
+ return withoutBrackets.toLowerCase().replace(/\.$/, "");
138
+ }
139
+
140
+ function parseIpv4(address: string): readonly number[] | null {
141
+ const pieces = address.split(".");
142
+ if (pieces.length !== 4) return null;
143
+ const numbers = pieces.map((piece) => Number(piece));
144
+ return numbers.every((piece) => Number.isInteger(piece) && piece >= 0 && piece <= 255)
145
+ ? numbers
146
+ : null;
147
+ }
148
+
149
+ function parseIpv6(address: string): readonly number[] | null {
150
+ let value = address;
151
+ const ipv4Separator = value.lastIndexOf(":");
152
+ const ipv4Tail = ipv4Separator >= 0 ? value.slice(ipv4Separator + 1) : value;
153
+ if (ipv4Tail.includes(".")) {
154
+ const ipv4 = parseIpv4(ipv4Tail);
155
+ if (ipv4 === null || ipv4Separator < 0) return null;
156
+ const high = ((ipv4[0] ?? 0) << 8) | (ipv4[1] ?? 0);
157
+ const low = ((ipv4[2] ?? 0) << 8) | (ipv4[3] ?? 0);
158
+ value = `${value.slice(0, ipv4Separator)}:${high.toString(16)}:${low.toString(16)}`;
159
+ }
160
+
161
+ const compression = value.indexOf("::");
162
+ if (compression !== -1 && compression !== value.lastIndexOf("::")) return null;
163
+ const leftText = compression === -1 ? value : value.slice(0, compression);
164
+ const rightText = compression === -1 ? "" : value.slice(compression + 2);
165
+ const left = leftText === "" ? [] : leftText.split(":");
166
+ const right = rightText === "" ? [] : rightText.split(":");
167
+ if ([...left, ...right].some((part) => !/^[0-9a-f]{1,4}$/i.test(part))) return null;
168
+ const missing = 8 - left.length - right.length;
169
+ if ((compression === -1 && missing !== 0) || (compression !== -1 && missing < 1)) return null;
170
+ const groups = [
171
+ ...left.map((part) => Number.parseInt(part, 16)),
172
+ ...Array.from({ length: Math.max(0, missing) }, () => 0),
173
+ ...right.map((part) => Number.parseInt(part, 16)),
174
+ ];
175
+ return groups.length === 8 ? groups : null;
176
+ }
177
+
178
+ function addressWithoutScope(address: string): string {
179
+ const normalized = normalizeHostname(address);
180
+ const scope = normalized.indexOf("%");
181
+ return scope === -1 ? normalized : normalized.slice(0, scope);
182
+ }
183
+
184
+ /**
185
+ * Return syntax-independent comparison keys for one IP literal. IPv4-mapped and deprecated
186
+ * IPv4-compatible IPv6 forms also receive their IPv4 key because either form can reach the same
187
+ * local IPv4 interface on platforms that enable mapped sockets.
188
+ */
189
+ function comparableAddressKeys(address: string): readonly string[] {
190
+ const normalized = addressWithoutScope(address);
191
+ const version = isIP(normalized);
192
+ if (version === 4) {
193
+ const pieces = parseIpv4(normalized);
194
+ return pieces === null ? [] : [`4:${pieces.join(".")}`];
195
+ }
196
+ if (version !== 6) return [];
197
+ const groups = parseIpv6(normalized);
198
+ if (groups === null) return [];
199
+ const keys = [`6:${groups.map((group) => group.toString(16).padStart(4, "0")).join(":")}`];
200
+ const ipv4Compatible = groups.slice(0, 6).every((group) => group === 0);
201
+ const ipv4Mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
202
+ if (ipv4Compatible || ipv4Mapped) {
203
+ const high = groups[6] ?? 0;
204
+ const low = groups[7] ?? 0;
205
+ keys.push(`4:${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`);
206
+ }
207
+ return keys;
208
+ }
209
+
210
+ function systemLocalNetworkAddresses(): readonly string[] {
211
+ let interfaces: ReturnType<typeof networkInterfaces>;
212
+ try {
213
+ interfaces = networkInterfaces();
214
+ } catch (error) {
215
+ throw new FetchFailure("network", "could not inspect local network interfaces", { cause: error });
216
+ }
217
+ const addresses: string[] = [];
218
+ for (const records of Object.values(interfaces)) {
219
+ if (!Array.isArray(records)) continue;
220
+ for (const record of records) {
221
+ if (typeof record?.address === "string") addresses.push(record.address);
222
+ }
223
+ }
224
+ return addresses;
225
+ }
226
+
227
+ function localAddressKeys(provider: LocalNetworkAddressProvider): Set<string> {
228
+ let addresses: readonly string[];
229
+ try {
230
+ addresses = provider();
231
+ } catch (error) {
232
+ if (error instanceof FetchFailure) throw error;
233
+ throw new FetchFailure("network", "could not inspect local network interfaces", { cause: error });
234
+ }
235
+ const keys = new Set<string>();
236
+ for (const address of addresses) {
237
+ for (const key of comparableAddressKeys(address)) keys.add(key);
238
+ }
239
+ return keys;
240
+ }
241
+
242
+ function isAssignedLocalAddress(address: string, localKeys: ReadonlySet<string>): boolean {
243
+ return comparableAddressKeys(address).some((key) => localKeys.has(key));
244
+ }
245
+
246
+ /** Return true for loopback, link-local, private, reserved, multicast, and unspecified addresses. */
247
+ export function isPrivateAddress(address: string): boolean {
248
+ const normalized = addressWithoutScope(address);
249
+ const version = isIP(normalized);
250
+ if (version === 4) {
251
+ const pieces = parseIpv4(normalized);
252
+ if (pieces === null) return true;
253
+ const a = pieces[0] ?? 0;
254
+ const b = pieces[1] ?? 0;
255
+ return a === 0
256
+ || a === 10
257
+ || a === 127
258
+ || (a === 100 && b >= 64 && b <= 127)
259
+ || (a === 169 && b === 254)
260
+ || (a === 172 && b >= 16 && b <= 31)
261
+ || (a === 192 && (b === 0 || b === 168))
262
+ || (a === 192 && b === 0 && (pieces[2] ?? 0) === 2)
263
+ || (a === 198 && (b === 18 || b === 19))
264
+ || (a === 198 && b === 51 && (pieces[2] ?? 0) === 100)
265
+ || (a === 203 && b === 0 && (pieces[2] ?? 0) === 113)
266
+ || a >= 224;
267
+ }
268
+ if (version === 6) {
269
+ const groups = parseIpv6(normalized);
270
+ if (groups === null) return true;
271
+ const first = groups[0] ?? 0;
272
+ const second = groups[1] ?? 0;
273
+ const firstSixAreZero = groups.slice(0, 6).every((group) => group === 0);
274
+ const ipv4Compatible = firstSixAreZero;
275
+ const ipv4Mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
276
+ if (ipv4Compatible || ipv4Mapped) {
277
+ const high = groups[6] ?? 0;
278
+ const low = groups[7] ?? 0;
279
+ return isPrivateAddress(`${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`);
280
+ }
281
+ return (first & 0xfe00) === 0xfc00
282
+ || (first & 0xffc0) === 0xfe80
283
+ || (first & 0xffc0) === 0xfec0
284
+ || (first & 0xff00) === 0xff00
285
+ || first === 0x0064
286
+ || first === 0x0100
287
+ || (first === 0x2001 && !Number.isNaN(second) && second <= 0x01ff)
288
+ || (first === 0x2001 && second === 0x0db8)
289
+ || first === 0x2002
290
+ || first === 0x3ffe
291
+ || (first === 0x3fff && !Number.isNaN(second) && (second & 0xf000) === 0)
292
+ || first === 0x5f00;
293
+ }
294
+ return true;
295
+ }
296
+
297
+ export function isPrivateHostname(hostname: string): boolean {
298
+ const normalized = normalizeHostname(hostname);
299
+ if (normalized === "localhost" || normalized === "localhost.localdomain") return true;
300
+ if (privateHostnameSuffixes.some((suffix) => normalized.endsWith(suffix))) return true;
301
+ return isIP(normalized) !== 0 && isPrivateAddress(normalized);
302
+ }
303
+
304
+ async function systemResolveHostname(hostname: string): Promise<readonly ResolvedNetworkAddress[]> {
305
+ const answers = await lookup(hostname, { all: true, verbatim: true });
306
+ return answers.flatMap((answer) =>
307
+ answer.family === 4 || answer.family === 6 ? [{ address: answer.address, family: answer.family }] : [],
308
+ );
309
+ }
310
+
311
+ async function beforeDeadline<T>(promise: Promise<T>, deadline: number, timeoutMessage: string): Promise<T> {
312
+ const remainingMs = deadline - Date.now();
313
+ if (remainingMs <= 0) throw new FetchFailure("timeout", timeoutMessage);
314
+ let timeout: ReturnType<typeof setTimeout> | undefined;
315
+ try {
316
+ return await Promise.race([
317
+ promise,
318
+ new Promise<never>((_resolve, reject) => {
319
+ timeout = setTimeout(() => reject(new FetchFailure("timeout", timeoutMessage)), remainingMs);
320
+ }),
321
+ ]);
322
+ } finally {
323
+ if (timeout !== undefined) clearTimeout(timeout);
324
+ }
325
+ }
326
+
327
+ async function resolveNetworkTarget(
328
+ url: URL,
329
+ allowPrivateNetwork: boolean,
330
+ resolveHostname: NetworkResolver,
331
+ getLocalNetworkAddresses: LocalNetworkAddressProvider,
332
+ deadline: number,
333
+ timeoutMs: number,
334
+ ): Promise<readonly ResolvedNetworkAddress[]> {
335
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
336
+ throw new FetchFailure("invalid-url", `unsupported URL protocol: ${url.protocol}`);
337
+ }
338
+ if (url.username !== "" || url.password !== "") {
339
+ throw new FetchFailure("invalid-url", "credential-bearing URLs are not accepted; use a local cookie file or browser session");
340
+ }
341
+ const hostname = normalizeHostname(url.hostname);
342
+ if (!allowPrivateNetwork && isPrivateHostname(hostname)) {
343
+ throw new FetchFailure("private-network", `private-network URL requires --allow-private-network: ${url.origin}`);
344
+ }
345
+ const assignedLocalAddresses = allowPrivateNetwork
346
+ ? new Set<string>()
347
+ : localAddressKeys(getLocalNetworkAddresses);
348
+
349
+ const literalFamily = isIP(hostname);
350
+ if (literalFamily === 4 || literalFamily === 6) {
351
+ if (isAssignedLocalAddress(hostname, assignedLocalAddresses)) {
352
+ throw new FetchFailure(
353
+ "private-network",
354
+ `address ${hostname} is assigned to a local interface; use --allow-private-network only when intended`,
355
+ );
356
+ }
357
+ return [{ address: hostname, family: literalFamily }];
358
+ }
359
+
360
+ let answers: readonly ResolvedNetworkAddress[];
361
+ try {
362
+ answers = await beforeDeadline(
363
+ resolveHostname(hostname),
364
+ deadline,
365
+ `request timed out after ${timeoutMs}ms while resolving ${hostname}`,
366
+ );
367
+ } catch (error) {
368
+ if (error instanceof FetchFailure) throw error;
369
+ throw new FetchFailure("dns", `could not resolve ${hostname}`, { cause: error });
370
+ }
371
+ if (answers.length === 0) throw new FetchFailure("dns", `could not resolve ${hostname}`);
372
+ if (!allowPrivateNetwork) {
373
+ // DNS can consume most of the request deadline. Merge a second snapshot so an interface that
374
+ // appeared while resolution was in flight cannot turn a public-looking answer into a local hop.
375
+ for (const key of localAddressKeys(getLocalNetworkAddresses)) assignedLocalAddresses.add(key);
376
+ }
377
+
378
+ const unique = new Map<string, ResolvedNetworkAddress>();
379
+ for (const answer of answers) {
380
+ const address = normalizeHostname(answer.address);
381
+ const actualFamily = isIP(address);
382
+ if ((actualFamily !== 4 && actualFamily !== 6) || actualFamily !== answer.family) {
383
+ throw new FetchFailure("dns", `${hostname} returned an invalid DNS answer`);
384
+ }
385
+ if (!allowPrivateNetwork && isPrivateAddress(address)) {
386
+ throw new FetchFailure(
387
+ "private-network",
388
+ `${hostname} resolves to private or reserved address ${address}; use --allow-private-network only when intended`,
389
+ );
390
+ }
391
+ if (!allowPrivateNetwork && isAssignedLocalAddress(address, assignedLocalAddresses)) {
392
+ throw new FetchFailure(
393
+ "private-network",
394
+ `${hostname} resolves to an address assigned to a local interface; use --allow-private-network only when intended`,
395
+ );
396
+ }
397
+ unique.set(`${answer.family}:${address}`, { address, family: answer.family });
398
+ }
399
+ return [...unique.values()];
400
+ }
401
+
402
+ /** Resolve one URL into validated connection candidates that are safe to pin at the transport boundary. */
403
+ export async function resolveSafeNetworkTarget(
404
+ url: URL,
405
+ options: SafeNetworkTargetOptions,
406
+ ): Promise<readonly ResolvedNetworkAddress[]> {
407
+ const timeoutMs = options.timeoutMs ?? 30_000;
408
+ return await resolveNetworkTarget(
409
+ url,
410
+ options.allowPrivateNetwork,
411
+ options.resolveHostname ?? systemResolveHostname,
412
+ options.getLocalNetworkAddresses ?? systemLocalNetworkAddresses,
413
+ Date.now() + timeoutMs,
414
+ timeoutMs,
415
+ );
416
+ }
417
+
418
+ /** Refuse private, reserved, or host-assigned network targets unless explicitly enabled. */
419
+ export async function assertSafeNetworkUrl(
420
+ url: URL,
421
+ allowPrivateNetwork: boolean,
422
+ timeoutMs = 30_000,
423
+ ): Promise<void> {
424
+ await resolveSafeNetworkTarget(url, { allowPrivateNetwork, timeoutMs });
425
+ }
426
+
427
+ /** A Node lookup callback that can return only a previously validated address. */
428
+ export function createPinnedLookup(pinned: ResolvedNetworkAddress): LookupFunction {
429
+ const address = pinned.address;
430
+ const family = pinned.family;
431
+ return (_hostname, options, callback) => {
432
+ queueMicrotask(() => {
433
+ if (options.all === true) {
434
+ callback(null, [{ address, family }]);
435
+ return;
436
+ }
437
+ callback(null, address, family);
438
+ });
439
+ };
440
+ }
441
+
442
+ function responseHeaders(headers: IncomingHttpHeaders): Headers {
443
+ const result = new Headers();
444
+ for (const [name, value] of Object.entries(headers)) {
445
+ if (typeof value === "string") result.append(name, value);
446
+ else if (Array.isArray(value)) {
447
+ for (const item of value) result.append(name, item);
448
+ }
449
+ }
450
+ return result;
451
+ }
452
+
453
+ function requestHeaders(headers: Headers): Readonly<Record<string, string>> {
454
+ const result: Record<string, string> = {};
455
+ headers.forEach((value, name) => {
456
+ result[name] = value;
457
+ });
458
+ return result;
459
+ }
460
+
461
+ type PreparedPinnedNetworkRequest = {
462
+ readonly hostname: string;
463
+ readonly options: RequestOptions;
464
+ };
465
+
466
+ function preparePinnedNetworkRequest(
467
+ request: PinnedNetworkRequest,
468
+ ): PreparedPinnedNetworkRequest {
469
+ const hostname = normalizeHostname(request.url.hostname);
470
+ if (request.url.protocol !== "http:" && request.url.protocol !== "https:") {
471
+ throw new Error(`unsupported pinned request protocol: ${request.url.protocol}`);
472
+ }
473
+ if (isIP(request.address.address) !== request.address.family) {
474
+ throw new Error("pinned request address does not match its declared family");
475
+ }
476
+ const hostnameFamily = isIP(hostname);
477
+ if (hostnameFamily !== 0) {
478
+ const hostnameKey = comparableAddressKeys(hostname)[0];
479
+ const pinnedKey = comparableAddressKeys(request.address.address)[0];
480
+ const pinnedAddressHasScope =
481
+ normalizeHostname(request.address.address)
482
+ !== addressWithoutScope(request.address.address);
483
+ if (
484
+ hostnameFamily !== request.address.family
485
+ || pinnedAddressHasScope
486
+ || hostnameKey === undefined
487
+ || hostnameKey !== pinnedKey
488
+ ) {
489
+ throw new Error("IP-literal request hostname does not match the pinned address");
490
+ }
491
+ }
492
+ return {
493
+ hostname,
494
+ options: {
495
+ protocol: request.url.protocol,
496
+ hostname,
497
+ method: request.method,
498
+ path: `${request.url.pathname}${request.url.search}`,
499
+ headers: requestHeaders(request.headers),
500
+ lookup: createPinnedLookup(request.address),
501
+ family: request.address.family,
502
+ signal: request.signal,
503
+ ...(request.url.port === "" ? {} : { port: request.url.port }),
504
+ },
505
+ };
506
+ }
507
+
508
+ function sendPreparedPinnedNetworkRequest(
509
+ request: PinnedNetworkRequest,
510
+ prepared: PreparedPinnedNetworkRequest,
511
+ agent: RequestOptions["agent"],
512
+ activeRequests?: Set<ClientRequest>,
513
+ activeSockets?: Set<Socket>,
514
+ ): Promise<PinnedNetworkResponse> {
515
+ return new Promise((resolve, reject) => {
516
+ const onResponse = (response: IncomingMessage): void => {
517
+ if (response.statusCode === undefined) {
518
+ response.destroy();
519
+ reject(new Error("HTTP response omitted a status code"));
520
+ return;
521
+ }
522
+ resolve({
523
+ status: response.statusCode,
524
+ headers: responseHeaders(response.headers),
525
+ body: response,
526
+ cancel: () => response.destroy(),
527
+ });
528
+ };
529
+ const requestOptions: RequestOptions = {
530
+ ...prepared.options,
531
+ agent,
532
+ };
533
+ const clientRequest = request.url.protocol === "https:"
534
+ ? requestHttps(
535
+ {
536
+ ...requestOptions,
537
+ ...(isIP(prepared.hostname) === 0
538
+ ? { servername: prepared.hostname }
539
+ : {}),
540
+ },
541
+ onResponse,
542
+ )
543
+ : requestHttp(requestOptions, onResponse);
544
+ activeRequests?.add(clientRequest);
545
+ clientRequest.once("close", () => activeRequests?.delete(clientRequest));
546
+ clientRequest.once("socket", (socket) => {
547
+ activeSockets?.add(socket);
548
+ socket.once("close", () => activeSockets?.delete(socket));
549
+ });
550
+ // A pooled agent can report a terminal handshake error and then a socket
551
+ // teardown error. Retain the listener for the request lifetime so the
552
+ // second event cannot become an uncaught process error after the promise
553
+ // has already rejected.
554
+ clientRequest.on("error", reject);
555
+ clientRequest.end(request.body ?? undefined);
556
+ });
557
+ }
558
+
559
+ /**
560
+ * Send exactly one HTTP(S) request through one previously validated address.
561
+ *
562
+ * This is a raw transport boundary: it does not resolve DNS, follow redirects, retry failures,
563
+ * interpret statuses, or accumulate the response body. Callers retain those higher-level policies.
564
+ */
565
+ export function requestPinnedNetworkAddress(request: PinnedNetworkRequest): Promise<PinnedNetworkResponse> {
566
+ try {
567
+ return sendPreparedPinnedNetworkRequest(
568
+ request,
569
+ preparePinnedNetworkRequest(request),
570
+ false,
571
+ );
572
+ } catch (error) {
573
+ return Promise.reject(
574
+ error instanceof Error
575
+ ? error
576
+ : new Error("pinned network request preparation failed", { cause: error }),
577
+ );
578
+ }
579
+ }
580
+
581
+ function createPinnedNetworkAgent(
582
+ request: PinnedNetworkRequest,
583
+ options: PinnedNetworkConnectionPoolOptions,
584
+ ): HttpAgent | HttpsAgent {
585
+ return request.url.protocol === "https:"
586
+ ? new HttpsAgent({
587
+ keepAlive: true,
588
+ maxFreeSockets: 1,
589
+ maxSockets: 1,
590
+ ...(options.certificateAuthorities === undefined
591
+ ? {}
592
+ : { ca: options.certificateAuthorities }),
593
+ })
594
+ : new HttpAgent({ keepAlive: true, maxFreeSockets: 1, maxSockets: 1 });
595
+ }
596
+
597
+ /**
598
+ * Reuse pinned HTTP(S) connections within one explicitly closed lifetime.
599
+ *
600
+ * Agents are isolated by exact origin and validated IP address. Every connection
601
+ * still uses the prepared request's pinned lookup, so re-resolution may select a
602
+ * new address without letting an older keep-alive socket bypass the caller's pin.
603
+ */
604
+ export function createPinnedNetworkConnectionPool(
605
+ options: PinnedNetworkConnectionPoolOptions = {},
606
+ ): PinnedNetworkConnectionPool {
607
+ // Bun 1.3.14's node:http compatibility layer pools outside the supplied
608
+ // Agent: distinct Agent instances can reuse one TCP socket and destroy()
609
+ // does not reliably close that shared idle socket. Fail closed to one
610
+ // connection per request there. Node uses genuinely isolated per-key agents.
611
+ const supportsIsolatedAgents = process.versions.bun === undefined;
612
+ const agents = new Map<string, HttpAgent | HttpsAgent>();
613
+ const activeRequests = new Set<ClientRequest>();
614
+ const activeSockets = new Set<Socket>();
615
+ let closed = false;
616
+
617
+ const close = (): void => {
618
+ if (closed) return;
619
+ closed = true;
620
+ for (const request of activeRequests) {
621
+ request.destroy();
622
+ }
623
+ activeRequests.clear();
624
+ for (const socket of activeSockets) {
625
+ socket.destroy();
626
+ }
627
+ activeSockets.clear();
628
+ for (const agent of agents.values()) {
629
+ agent.destroy();
630
+ }
631
+ agents.clear();
632
+ };
633
+
634
+ const pooledRequest: NetworkTransport = async (request) => {
635
+ if (closed) {
636
+ throw new Error("pinned network connection pool is closed");
637
+ }
638
+ const prepared = preparePinnedNetworkRequest(request);
639
+ const addressKey = comparableAddressKeys(request.address.address)[0];
640
+ if (addressKey === undefined) {
641
+ throw new Error("pinned request address is invalid");
642
+ }
643
+ const key = [
644
+ request.url.origin,
645
+ request.address.family,
646
+ addressKey,
647
+ ].join("\0");
648
+ if (closed) throw new Error("pinned network connection pool is closed");
649
+ if (request.signal.aborted) {
650
+ const reason: unknown = request.signal.reason;
651
+ throw reason instanceof Error ? reason : new Error("pinned network request aborted");
652
+ }
653
+ if (!supportsIsolatedAgents) {
654
+ return await sendPreparedPinnedNetworkRequest(
655
+ request,
656
+ {
657
+ ...prepared,
658
+ options: {
659
+ ...prepared.options,
660
+ headers: {
661
+ ...requestHeaders(request.headers),
662
+ connection: "close",
663
+ },
664
+ ...(options.certificateAuthorities === undefined
665
+ ? {}
666
+ : { ca: options.certificateAuthorities }),
667
+ },
668
+ },
669
+ false,
670
+ activeRequests,
671
+ activeSockets,
672
+ );
673
+ }
674
+ let agent = agents.get(key);
675
+ if (agent === undefined) {
676
+ agent = createPinnedNetworkAgent(request, options);
677
+ agents.set(key, agent);
678
+ }
679
+ return await sendPreparedPinnedNetworkRequest(
680
+ request,
681
+ prepared,
682
+ agent,
683
+ activeRequests,
684
+ activeSockets,
685
+ );
686
+ };
687
+
688
+ return Object.freeze({
689
+ request: pooledRequest,
690
+ close,
691
+ });
692
+ }
693
+
694
+ function retryDelay(response: PinnedNetworkResponse | null, attempt: number): number {
695
+ const header = response?.headers.get("retry-after")?.trim();
696
+ if (header !== undefined && header !== null) {
697
+ const seconds = Number(header);
698
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1_000, 5_000);
699
+ const date = Date.parse(header);
700
+ if (!Number.isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), 5_000);
701
+ }
702
+ return Math.min(250 * 2 ** attempt, 2_000);
703
+ }
704
+
705
+ const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
706
+
707
+ async function readBounded(response: PinnedNetworkResponse, maxBytes: number): Promise<Uint8Array> {
708
+ const declared = response.headers.get("content-length");
709
+ if (declared !== null) {
710
+ const length = Number(declared);
711
+ if (Number.isFinite(length) && length > maxBytes) {
712
+ response.cancel();
713
+ throw new FetchFailure("too-large", `response declares ${length} bytes; limit is ${maxBytes}`);
714
+ }
715
+ }
716
+ if (response.body === null) return new Uint8Array();
717
+ const bytes = new BoundedByteBuffer(maxBytes);
718
+ for await (const value of response.body) {
719
+ if (!(value instanceof Uint8Array)) {
720
+ response.cancel();
721
+ throw new FetchFailure("network", "response body yielded a non-byte chunk");
722
+ }
723
+ if (!bytes.append(value)) {
724
+ response.cancel();
725
+ throw new FetchFailure("too-large", `response exceeded ${maxBytes} bytes`);
726
+ }
727
+ }
728
+ return bytes.toUint8Array();
729
+ }
730
+
731
+ function buildHeaders(current: URL, originalUrl: URL, options: SafeFetchOptions): Headers {
732
+ const headers = new Headers({
733
+ Accept: options.accept ?? "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5",
734
+ "Accept-Encoding": "identity",
735
+ "User-Agent": options.userAgent,
736
+ });
737
+ if (options.referer !== undefined) {
738
+ try {
739
+ const referer = new URL(options.referer);
740
+ headers.set("Referer", referer.origin === current.origin ? referer.href : `${referer.origin}/`);
741
+ } catch {
742
+ // Ignore an invalid optional referrer instead of forwarding arbitrary text.
743
+ }
744
+ }
745
+ if (options.cookieHeader !== undefined && current.href === originalUrl.href) {
746
+ headers.set("Cookie", options.cookieHeader);
747
+ }
748
+ return headers;
749
+ }
750
+
751
+ /** Build a bounded fetcher around an explicitly pinned transport and resolver. */
752
+ export function createSafeFetch(
753
+ dependencies: Partial<SafeFetchDependencies> = {},
754
+ ): (url: URL, options: SafeFetchOptions) => Promise<SafeFetchResult> {
755
+ const resolveHostname = dependencies.resolveHostname ?? systemResolveHostname;
756
+ const transport = dependencies.transport ?? requestPinnedNetworkAddress;
757
+ const getLocalNetworkAddresses = dependencies.getLocalNetworkAddresses ?? systemLocalNetworkAddresses;
758
+
759
+ return async (url, options) => {
760
+ const maxRedirects = options.maxRedirects ?? 8;
761
+ const retries = options.retries ?? 2;
762
+ const acceptedStatuses = new Set(options.acceptStatuses ?? []);
763
+ if (
764
+ acceptedStatuses.size > 8
765
+ || [...acceptedStatuses].some((status) => !Number.isInteger(status) || status < 400 || status > 599)
766
+ ) {
767
+ throw new FetchFailure("invalid-url", "accepted response statuses must contain at most eight HTTP 4xx/5xx integers");
768
+ }
769
+ const allowedRedirectOrigins = options.allowedRedirectOrigins === undefined
770
+ ? null
771
+ : new Set(options.allowedRedirectOrigins.map((origin) => {
772
+ let parsed: URL;
773
+ try {
774
+ parsed = new URL(origin);
775
+ } catch (error) {
776
+ throw new FetchFailure("invalid-url", "allowed redirect origins must be absolute HTTP(S) origins", { cause: error });
777
+ }
778
+ if (
779
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:")
780
+ || parsed.username !== ""
781
+ || parsed.password !== ""
782
+ || parsed.pathname !== "/"
783
+ || parsed.search !== ""
784
+ || parsed.hash !== ""
785
+ || parsed.href !== `${parsed.origin}/`
786
+ ) {
787
+ throw new FetchFailure("invalid-url", "allowed redirect origins must be canonical HTTP(S) origins");
788
+ }
789
+ return parsed.origin;
790
+ }));
791
+ const deadline = Date.now() + options.timeoutMs;
792
+ const originalUrl = new URL(url);
793
+ let current = new URL(url);
794
+
795
+ for (let redirects = 0; redirects <= maxRedirects; redirects += 1) {
796
+ const addresses = await resolveNetworkTarget(
797
+ current,
798
+ options.allowPrivateNetwork,
799
+ resolveHostname,
800
+ getLocalNetworkAddresses,
801
+ deadline,
802
+ options.timeoutMs,
803
+ );
804
+ let response: PinnedNetworkResponse | null = null;
805
+ let lastError: unknown;
806
+ let finalController: AbortController | null = null;
807
+ let finalTimeout: ReturnType<typeof setTimeout> | null = null;
808
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
809
+ const remainingMs = deadline - Date.now();
810
+ if (remainingMs <= 0) {
811
+ throw new FetchFailure("timeout", `request timed out after ${options.timeoutMs}ms: ${current.href}`);
812
+ }
813
+ const controller = new AbortController();
814
+ const timeout = setTimeout(() => controller.abort(), remainingMs);
815
+ try {
816
+ const address = addresses[attempt % addresses.length];
817
+ if (address === undefined) throw new FetchFailure("dns", `could not resolve ${current.hostname}`);
818
+ response = await beforeDeadline(
819
+ transport({
820
+ url: new URL(current),
821
+ address,
822
+ method: "GET",
823
+ headers: buildHeaders(current, originalUrl, options),
824
+ body: null,
825
+ signal: controller.signal,
826
+ }),
827
+ deadline,
828
+ `request timed out after ${options.timeoutMs}ms: ${current.href}`,
829
+ );
830
+ if (!retryableStatuses.has(response.status) || attempt === retries) {
831
+ finalController = controller;
832
+ finalTimeout = timeout;
833
+ break;
834
+ }
835
+ controller.abort();
836
+ response.cancel();
837
+ clearTimeout(timeout);
838
+ } catch (error) {
839
+ lastError = error;
840
+ // `beforeDeadline` and the abort timer share a deadline but are separate callbacks.
841
+ // Abort first when either one wins so a still-pending transport cannot retain its socket.
842
+ controller.abort();
843
+ clearTimeout(timeout);
844
+ if (error instanceof FetchFailure && error.code === "timeout") throw error;
845
+ if (attempt === retries) {
846
+ if (controller.signal.aborted) {
847
+ throw new FetchFailure("timeout", `request timed out after ${options.timeoutMs}ms: ${current.href}`, {
848
+ cause: error,
849
+ });
850
+ }
851
+ if (error instanceof FetchFailure) throw error;
852
+ throw new FetchFailure("network", `request failed: ${current.href}`, { cause: error });
853
+ }
854
+ }
855
+ const delay = Math.min(retryDelay(response, attempt), Math.max(0, deadline - Date.now()));
856
+ if (delay > 0) await Bun.sleep(delay);
857
+ }
858
+ if (response === null) {
859
+ throw new FetchFailure("network", `request failed: ${current.href}`, { cause: lastError });
860
+ }
861
+
862
+ if (response.status >= 300 && response.status < 400) {
863
+ if (options.redirect === "manual") {
864
+ try {
865
+ const location = response.headers.get("location");
866
+ response.cancel();
867
+ return {
868
+ bytes: new Uint8Array(),
869
+ finalUrl: current,
870
+ status: response.status,
871
+ contentType: response.headers.get("content-type"),
872
+ etag: response.headers.get("etag"),
873
+ lastModified: response.headers.get("last-modified"),
874
+ location,
875
+ };
876
+ } finally {
877
+ finalController?.abort();
878
+ if (finalTimeout !== null) clearTimeout(finalTimeout);
879
+ }
880
+ }
881
+ try {
882
+ const location = response.headers.get("location");
883
+ if (location === null) throw new FetchFailure("redirect", `HTTP ${response.status} omitted Location`);
884
+ if (redirects === maxRedirects) throw new FetchFailure("redirect", `more than ${maxRedirects} redirects`);
885
+ let next: URL;
886
+ try {
887
+ next = new URL(location, current);
888
+ } catch (error) {
889
+ throw new FetchFailure("redirect", `invalid redirect target: ${location}`, { cause: error });
890
+ }
891
+ if (allowedRedirectOrigins !== null && !allowedRedirectOrigins.has(next.origin)) {
892
+ throw new FetchFailure("redirect", `redirect target origin is not allowed: ${next.origin}`);
893
+ }
894
+ current = next;
895
+ continue;
896
+ } finally {
897
+ finalController?.abort();
898
+ response.cancel();
899
+ if (finalTimeout !== null) clearTimeout(finalTimeout);
900
+ }
901
+ }
902
+
903
+ if ((response.status < 200 || response.status >= 300) && !acceptedStatuses.has(response.status)) {
904
+ if (finalTimeout !== null) clearTimeout(finalTimeout);
905
+ finalController?.abort();
906
+ response.cancel();
907
+ throw new FetchFailure("http", `HTTP ${response.status} for ${current.href}`, { status: response.status });
908
+ }
909
+ try {
910
+ const bytes = await beforeDeadline(
911
+ readBounded(response, options.maxBytes),
912
+ deadline,
913
+ `response body timed out after ${options.timeoutMs}ms: ${current.href}`,
914
+ );
915
+ return {
916
+ bytes,
917
+ finalUrl: current,
918
+ status: response.status,
919
+ contentType: response.headers.get("content-type"),
920
+ etag: response.headers.get("etag"),
921
+ lastModified: response.headers.get("last-modified"),
922
+ location: null,
923
+ };
924
+ } catch (error) {
925
+ if (finalController?.signal.aborted === true || (error instanceof FetchFailure && error.code === "timeout")) {
926
+ response.cancel();
927
+ throw new FetchFailure("timeout", `response body timed out after ${options.timeoutMs}ms: ${current.href}`, {
928
+ cause: error,
929
+ });
930
+ }
931
+ if (error instanceof FetchFailure) throw error;
932
+ throw new FetchFailure("network", `response body failed: ${current.href}`, { cause: error });
933
+ } finally {
934
+ if (finalTimeout !== null) clearTimeout(finalTimeout);
935
+ }
936
+ }
937
+ throw new FetchFailure("redirect", `more than ${maxRedirects} redirects`);
938
+ };
939
+ }
940
+
941
+ /** Fetch one bounded resource with checked redirects and no cookie forwarding after the initial URL. */
942
+ export async function safeFetch(url: URL, options: SafeFetchOptions): Promise<SafeFetchResult> {
943
+ return await createSafeFetch()(url, options);
944
+ }
945
+
946
+ export function decodeBytes(bytes: Uint8Array, contentType: string | null): string {
947
+ const charset = /charset\s*=\s*["']?([^;"'\s]+)/i.exec(contentType ?? "")?.[1]?.toLowerCase();
948
+ const supported = charset === "iso-8859-1" || charset === "windows-1252" ? "windows-1252" : "utf-8";
949
+ return new TextDecoder(supported, { fatal: false }).decode(bytes);
950
+ }