@zeldrisho/pi-web-fetch 0.7.0 → 0.8.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.
package/src/fetch.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage } from "node:http";
2
2
  import { awaitWithAbort } from "./abort";
3
3
  import { sliceCompleteDocument, type CompleteDocument, type FetchResult } from "./content";
4
+ import { diagnoseExtraction, extractDocumentLinks, hasExtractionWarning } from "./evidence";
4
5
  import { extractHtmlToMarkdown } from "./extract";
5
6
  import { requestFollowingRedirects, type RedirectDependencies } from "./network-redirects";
6
7
  import type { ValidatedTarget } from "./network-policy";
@@ -27,8 +28,21 @@ export function normalizeGitHubRawUrl(rawUrl: string): string {
27
28
  const url = new URL(rawUrl);
28
29
  if (url.protocol !== "https:") return rawUrl;
29
30
  if (url.hostname === "github.com") {
30
- if (!url.pathname.includes("/blob/")) return rawUrl;
31
- return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
31
+ if (url.pathname.includes("/blob/")) {
32
+ return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
33
+ }
34
+ // Parse pathname segments: /owner/repo/tree/ref/path
35
+ const segments = url.pathname.split("/").filter(Boolean);
36
+ if (segments.length >= 4 && segments[2] === "tree") {
37
+ const lastSegment = segments[segments.length - 1];
38
+ if (lastSegment.includes(".")) {
39
+ // Reconstruct path: /owner/repo/ref/path (remove "tree" segment)
40
+ const newPath = `/${segments[0]}/${segments[1]}/${segments.slice(3).join("/")}`;
41
+ return `https://raw.githubusercontent.com${newPath}${url.search}`;
42
+ }
43
+ return rawUrl;
44
+ }
45
+ return rawUrl;
32
46
  }
33
47
  if (url.hostname === "gist.github.com") {
34
48
  const segments = url.pathname.split("/").filter(Boolean);
@@ -43,37 +57,9 @@ export function normalizeGitHubRawUrl(rawUrl: string): string {
43
57
  }
44
58
  }
45
59
 
46
- const APP_SHELL_MARKERS = [
47
- /please\s+enable\s+javascript/i,
48
- /enable\s+javascript/i,
49
- // Consent is only treated as an interstitial signal when it appears in a
50
- // cookie/consent-banner phrase. A bare "consent" matches ordinary prose
51
- // (e.g. privacy articles) and must not flag readable content as a shell.
52
- /manage\s+(your\s+)?consent/i,
53
- /your\s+(privacy\s+)?consent/i,
54
- /consent\s+to\s+(our\s+use\s+of\s+cookies|cookies)/i,
55
- /accept\s+(all\s+)?cookies/i,
56
- /we\s+use\s+cookies/i,
57
- /are\s+you\s+a\s+robot/i,
58
- /verify\s+you\s+are\s+human/i,
59
- /checking\s+your\s+browser/i,
60
- /<title>\s*just\s+a\s+moment/i,
61
- ];
62
-
63
- /**
64
- * Detects pages that are likely app shells, bot walls, or consent interstitials rather than
65
- * readable content.
66
- *
67
- * @param raw - The raw response body
68
- * @param markdown - The extracted Markdown
69
- * @returns True when the extracted text is suspiciously sparse relative to the raw page
70
- */
60
+ /** @deprecated Prefer diagnoseExtraction for explicit extraction-quality signals. */
71
61
  export function detectAppShell(raw: string, markdown: string): boolean {
72
- if (APP_SHELL_MARKERS.some((marker) => marker.test(raw))) return true;
73
- // Require the extracted text to be both absolutely tiny and a very small
74
- // fraction of the raw payload, so content-rich pages (e.g. React/Next.js SPAs
75
- // whose raw HTML is dominated by inline scripts) are not mistaken for shells.
76
- return raw.length > 4000 && markdown.length < 1024 && markdown.length < raw.length * 0.008;
62
+ return hasExtractionWarning(diagnoseExtraction(raw, markdown));
77
63
  }
78
64
 
79
65
  const REQUEST_TIMEOUT_MS = 20_000;
@@ -225,10 +211,13 @@ async function documentFromResponse(
225
211
  }
226
212
  } else markdown = raw.trim();
227
213
 
228
- const shellSuspected =
229
- contentType === "text/html" || contentType === "application/xhtml+xml"
230
- ? detectAppShell(raw, markdown)
231
- : false;
214
+ const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
215
+ const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
216
+ const shellSuspected = extractionDiagnostics
217
+ ? hasExtractionWarning(extractionDiagnostics)
218
+ : false;
219
+ const etag = responseHeader(response, "etag");
220
+ const lastModified = responseHeader(response, "last-modified");
232
221
 
233
222
  return {
234
223
  url: target.url.toString(),
@@ -237,16 +226,51 @@ async function documentFromResponse(
237
226
  title,
238
227
  extractor,
239
228
  shellSuspected,
229
+ extractionDiagnostics,
230
+ links: isHtml ? extractDocumentLinks(raw, target.url) : undefined,
231
+ validators: etag || lastModified ? { etag, lastModified } : undefined,
232
+ cachedAt: Date.now(),
240
233
  llmsTxtDescribedBy: describedBy,
241
234
  markdownAlternateUrl,
242
235
  };
243
236
  }
244
237
 
245
- export async function fetchCompleteDocument(
238
+ /**
239
+ * Validates that a URL string is an absolute http or https URL.
240
+ *
241
+ * @param value - The URL string to validate
242
+ * @returns The parsed URL object
243
+ * @throws If the URL is invalid or not http(s)
244
+ */
245
+ function assertAbsoluteHttpUrlForFetch(value: string): URL {
246
+ let url: URL;
247
+ try {
248
+ url = new URL(value);
249
+ } catch {
250
+ throw new Error(`Invalid URL: ${value}`);
251
+ }
252
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
253
+ throw new Error(`web_fetch only supports http(s) URLs: ${value}`);
254
+ }
255
+ return url;
256
+ }
257
+
258
+ /**
259
+ * Fetches a URL and returns the complete document with extracted content.
260
+ *
261
+ * @param rawUrl - The URL to fetch
262
+ * @param signal - Optional abort signal to cancel the request
263
+ * @param dependencies - Dependencies for network policy validation, transport, and HTML extraction
264
+ * @returns The complete document with extracted markdown content
265
+ * @throws If the request times out, is cancelled, or encounters an error
266
+ */
267
+ async function fetchDocument(
246
268
  rawUrl: string,
247
269
  signal: AbortSignal | undefined,
248
270
  dependencies: FetchRemoteDependencies,
249
- ): Promise<CompleteDocument> {
271
+ cached?: CompleteDocument,
272
+ ): Promise<{ document: CompleteDocument; revalidated: boolean }> {
273
+ assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
250
274
  const controller = new AbortController();
251
275
  const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
252
276
  const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
@@ -257,17 +281,36 @@ export async function fetchCompleteDocument(
257
281
  }, timeoutMs);
258
282
  const cancel = () => controller.abort();
259
283
  signal?.addEventListener("abort", cancel, { once: true });
284
+ const conditionalHeaders: Record<string, string> = {};
285
+ if (cached?.validators?.etag) conditionalHeaders["If-None-Match"] = cached.validators.etag;
286
+ if (cached?.validators?.lastModified)
287
+ conditionalHeaders["If-Modified-Since"] = cached.validators.lastModified;
260
288
 
261
289
  try {
262
290
  const { target, response } = await requestFollowingRedirects(
263
291
  normalizeGitHubRawUrl(rawUrl),
264
292
  controller.signal,
265
- {
266
- validateUrl: dependencies.validateUrl,
267
- request: dependencies.request,
268
- },
293
+ dependencies,
294
+ conditionalHeaders,
269
295
  );
270
- return await documentFromResponse(target, response, controller.signal, extractHtml);
296
+ if (response.statusCode === 304 && cached) {
297
+ response.resume();
298
+ const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
299
+ const lastModified =
300
+ responseHeader(response, "last-modified") ?? cached.validators?.lastModified;
301
+ return {
302
+ document: {
303
+ ...cached,
304
+ validators: etag || lastModified ? { etag, lastModified } : undefined,
305
+ cachedAt: Date.now(),
306
+ },
307
+ revalidated: true,
308
+ };
309
+ }
310
+ return {
311
+ document: await documentFromResponse(target, response, controller.signal, extractHtml),
312
+ revalidated: false,
313
+ };
271
314
  } catch (error) {
272
315
  if (timedOut) throw new Error(`web_fetch timed out after ${timeoutMs / 1000} seconds.`);
273
316
  if (signal?.aborted) throw new Error("web_fetch was cancelled.");
@@ -278,6 +321,34 @@ export async function fetchCompleteDocument(
278
321
  }
279
322
  }
280
323
 
324
+ export async function fetchCompleteDocument(
325
+ rawUrl: string,
326
+ signal: AbortSignal | undefined,
327
+ dependencies: FetchRemoteDependencies,
328
+ ): Promise<CompleteDocument> {
329
+ return (await fetchDocument(rawUrl, signal, dependencies)).document;
330
+ }
331
+
332
+ /** Revalidates a stale representation with its ETag and Last-Modified validators. */
333
+ export async function revalidateCompleteDocument(
334
+ rawUrl: string,
335
+ cached: CompleteDocument,
336
+ signal: AbortSignal | undefined,
337
+ dependencies: FetchRemoteDependencies,
338
+ ): Promise<{ document: CompleteDocument; revalidated: boolean }> {
339
+ return fetchDocument(rawUrl, signal, dependencies, cached);
340
+ }
341
+
342
+ /**
343
+ * Fetches a URL and returns a slice of the content starting at the given offset.
344
+ *
345
+ * @param rawUrl - The URL to fetch
346
+ * @param offset - The character offset to start slicing from
347
+ * @param maxCharacters - The maximum number of characters to return
348
+ * @param signal - Optional abort signal to cancel the request
349
+ * @param dependencies - Dependencies for network policy validation, transport, and HTML extraction
350
+ * @returns The sliced fetch result with content and metadata
351
+ */
281
352
  export async function fetchRemoteContent(
282
353
  rawUrl: string,
283
354
  offset: number,
package/src/focus.ts ADDED
@@ -0,0 +1,125 @@
1
+ const WORD_PATTERN = /[\p{L}\p{N}_+#]+/gu;
2
+ const HEADING_PATTERN = /^#{1,6}\s+\S/;
3
+
4
+ /** Metadata describing deterministic query-focused extraction. */
5
+ export interface FocusDetails {
6
+ query: string;
7
+ matchedSections: number;
8
+ totalSections: number;
9
+ omittedSections: number;
10
+ originalCharacters: number;
11
+ focusedCharacters: number;
12
+ }
13
+
14
+ export interface FocusResult {
15
+ markdown: string;
16
+ details: FocusDetails;
17
+ }
18
+
19
+ interface Section {
20
+ markdown: string;
21
+ tokens: string[];
22
+ }
23
+
24
+ function tokens(value: string): string[] {
25
+ return (value.toLowerCase().match(WORD_PATTERN) ?? []).filter((token) => token.length > 1);
26
+ }
27
+
28
+ /**
29
+ * Splits Markdown into sections. Headings stay attached to their following content; a
30
+ * headingless document falls back to paragraphs so focused extraction remains useful for
31
+ * plain-text and basic-extractor responses.
32
+ */
33
+ function splitSections(markdown: string): Section[] {
34
+ const blocks = markdown
35
+ .split(/\n{2,}/)
36
+ .map((block) => block.trim())
37
+ .filter(Boolean);
38
+ if (blocks.length === 0) return [];
39
+
40
+ const lines = markdown.split(/\r?\n/);
41
+ const hasHeadings = lines.some((line) => HEADING_PATTERN.test(line));
42
+ if (!hasHeadings) return blocks.map((block) => ({ markdown: block, tokens: tokens(block) }));
43
+
44
+ const sections: string[] = [];
45
+ let current: string[] = [];
46
+ const flush = () => {
47
+ const section = current.join("\n").trim();
48
+ if (section) sections.push(section);
49
+ current = [];
50
+ };
51
+ for (const line of lines) {
52
+ if (HEADING_PATTERN.test(line)) flush();
53
+ current.push(line);
54
+ }
55
+ flush();
56
+ return sections.map((section) => ({ markdown: section, tokens: tokens(section) }));
57
+ }
58
+
59
+ /**
60
+ * Produces a deterministic, query-focused view of Markdown using a compact BM25-style
61
+ * section ranker. Matching sections retain source order so the result remains readable
62
+ * and attributable to the fetched page. The complete document is never mutated.
63
+ */
64
+ export function focusMarkdown(markdown: string, query: string): FocusResult {
65
+ const normalizedQuery = query.trim();
66
+ const queryTerms = [...new Set(tokens(normalizedQuery))];
67
+ const sections = splitSections(markdown);
68
+ const originalCharacters = markdown.length;
69
+
70
+ if (queryTerms.length === 0 || sections.length === 0) {
71
+ return {
72
+ markdown: "",
73
+ details: {
74
+ query: normalizedQuery,
75
+ matchedSections: 0,
76
+ totalSections: sections.length,
77
+ omittedSections: sections.length,
78
+ originalCharacters,
79
+ focusedCharacters: 0,
80
+ },
81
+ };
82
+ }
83
+
84
+ const averageLength =
85
+ sections.reduce((sum, section) => sum + section.tokens.length, 0) / sections.length || 1;
86
+ const documentFrequency = new Map<string, number>();
87
+ for (const term of queryTerms) {
88
+ documentFrequency.set(
89
+ term,
90
+ sections.reduce((count, section) => count + (section.tokens.includes(term) ? 1 : 0), 0),
91
+ );
92
+ }
93
+
94
+ const selected = sections.filter((section) => {
95
+ const frequencies = new Map<string, number>();
96
+ for (const token of section.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
97
+ const lengthNormalization = 1 - 0.75 + 0.75 * (section.tokens.length / averageLength);
98
+ let score = 0;
99
+ for (const term of queryTerms) {
100
+ const frequency = frequencies.get(term) ?? 0;
101
+ if (frequency === 0) continue;
102
+ const frequencyInDocuments = documentFrequency.get(term) ?? 0;
103
+ const inverseDocumentFrequency = Math.log(
104
+ 1 + (sections.length - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5),
105
+ );
106
+ score +=
107
+ inverseDocumentFrequency *
108
+ ((frequency * (1.2 + 1)) / (frequency + 1.2 * lengthNormalization));
109
+ }
110
+ return score > 0;
111
+ });
112
+
113
+ const focused = selected.map((section) => section.markdown).join("\n\n");
114
+ return {
115
+ markdown: focused,
116
+ details: {
117
+ query: normalizedQuery,
118
+ matchedSections: selected.length,
119
+ totalSections: sections.length,
120
+ omittedSections: sections.length - selected.length,
121
+ originalCharacters,
122
+ focusedCharacters: focused.length,
123
+ },
124
+ };
125
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  FETCH_DEFAULT_OFFSET,
7
7
  FETCH_MAX_CHARACTERS,
8
8
  FETCH_MAX_OFFSET_CHARACTERS,
9
+ FETCH_MAX_QUERY_CHARACTERS,
9
10
  FETCH_MAX_URL_CHARACTERS,
10
11
  FETCH_MIN_MAX_CHARACTERS,
11
12
  } from "./limits";
@@ -14,9 +15,19 @@ import { executeWebFetch } from "./service";
14
15
 
15
16
  export { ExpiringLruCache } from "./cache";
16
17
  export type { FetchResult } from "./content";
18
+ export {
19
+ diagnoseExtraction,
20
+ extractDocumentLinks,
21
+ type ExtractedLink,
22
+ type ExtractedLinks,
23
+ type ExtractionDiagnostics,
24
+ } from "./evidence";
17
25
  export { fetchRemoteContent, type FetchRemoteDependencies } from "./fetch";
26
+ export { focusMarkdown, type FocusDetails, type FocusResult } from "./focus";
27
+ export { createDocumentOutline, type DocumentOutline, type OutlineHeading } from "./outline";
18
28
  export {
19
29
  executeWebFetch,
30
+ type CacheStatus,
20
31
  type WebFetchDetails,
21
32
  type WebFetchParameters,
22
33
  type WebFetchTruncationDetails,
@@ -35,6 +46,14 @@ export const webFetchParameters = Type.Object({
35
46
  maxLength: FETCH_MAX_URL_CHARACTERS,
36
47
  description: "Public HTTP or HTTPS URL to fetch",
37
48
  }),
49
+ query: Type.Optional(
50
+ Type.String({
51
+ minLength: 1,
52
+ maxLength: FETCH_MAX_QUERY_CHARACTERS,
53
+ description:
54
+ "Optional focus query; returns matching source sections in document order (offsets apply to the focused view)",
55
+ }),
56
+ ),
38
57
  offset: Type.Optional(
39
58
  Type.Integer({
40
59
  minimum: 0,
@@ -51,6 +70,14 @@ export const webFetchParameters = Type.Object({
51
70
  ),
52
71
  });
53
72
 
73
+ /**
74
+ * Pi web fetch extension that registers the web_fetch tool.
75
+ *
76
+ * Provides a tool for fetching public HTTP(S) pages and converting them to
77
+ * Markdown with support for pagination via offset/nextOffset parameters.
78
+ *
79
+ * @param pi - The extension API instance
80
+ */
54
81
  export default function (pi: ExtensionAPI) {
55
82
  pi.registerTool({
56
83
  name: "web_fetch",
package/src/inflight.ts CHANGED
@@ -5,6 +5,14 @@ interface InflightEntry<V> {
5
5
  waiters: number;
6
6
  }
7
7
 
8
+ /**
9
+ * Wraps a promise to respect a caller's abort signal without cancelling the underlying operation.
10
+ *
11
+ * @param operation - Promise to wait for
12
+ * @param signal - Optional abort signal for this caller
13
+ * @param cancelledMessage - Error message to use when signal is aborted
14
+ * @returns Promise that rejects if signal aborts, otherwise resolves with operation result
15
+ */
8
16
  function waitForCaller<T>(
9
17
  operation: Promise<T>,
10
18
  signal: AbortSignal | undefined,
@@ -28,10 +36,24 @@ export class InflightCoalescer<K, V> {
28
36
 
29
37
  constructor(readonly maxEntries: number) {}
30
38
 
39
+ /**
40
+ * Gets the number of currently in-flight operations.
41
+ *
42
+ * @returns Count of active operations
43
+ */
31
44
  get size(): number {
32
45
  return this.#entries.size;
33
46
  }
34
47
 
48
+ /**
49
+ * Runs an operation, coalescing with any identical in-flight operation.
50
+ *
51
+ * @param key - Operation key for coalescing identical operations
52
+ * @param operation - Function that performs the operation
53
+ * @param signal - Optional abort signal for this caller
54
+ * @param cancelledMessage - Error message when signal is aborted
55
+ * @returns Promise resolving to the operation result
56
+ */
35
57
  async run(
36
58
  key: K,
37
59
  operation: (signal: AbortSignal | undefined) => Promise<V>,
package/src/limits.ts CHANGED
@@ -1,6 +1,20 @@
1
+ /** Default starting offset for content extraction (characters). */
1
2
  export const FETCH_DEFAULT_OFFSET = 0;
3
+
4
+ /** Minimum allowed value for maxCharacters parameter. */
2
5
  export const FETCH_MIN_MAX_CHARACTERS = 1_000;
6
+
7
+ /** Default maximum characters to return per fetch. */
3
8
  export const FETCH_DEFAULT_MAX_CHARACTERS = 6_000;
9
+
10
+ /** Maximum allowed characters to return per fetch. */
4
11
  export const FETCH_MAX_CHARACTERS = 30_000;
12
+
13
+ /** Maximum allowed offset for content extraction (characters). */
5
14
  export const FETCH_MAX_OFFSET_CHARACTERS = 20_000_000;
15
+
16
+ /** Maximum allowed length for an optional focus query (characters). */
17
+ export const FETCH_MAX_QUERY_CHARACTERS = 400;
18
+
19
+ /** Maximum allowed length for input URLs (characters). */
6
20
  export const FETCH_MAX_URL_CHARACTERS = 2_048;
@@ -1,11 +1,13 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
2
  import { BlockList, isIP } from "node:net";
3
3
 
4
- // Reviewed 2026-08-03 against the IANA IPv4 and IPv6 Special-Purpose Address Registries:
5
- // https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
6
- // https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
7
- // Keep non-global, documentation, benchmarking, multicast, and reserved space blocked. The explicit
8
- // endpoints make registry reviews and boundary tests auditable without changing the pinning flow.
4
+ /**
5
+ * IPv4 address ranges that are blocked from network requests.
6
+ *
7
+ * Reviewed 2026-08-03 against the IANA IPv4 Special-Purpose Address Registry:
8
+ * https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
9
+ * Includes non-global, documentation, benchmarking, multicast, and reserved ranges.
10
+ */
9
11
  export const BLOCKED_IPV4_RANGES = [
10
12
  ["0.0.0.0", 8, "0.0.0.0", "0.255.255.255", "current network"],
11
13
  ["10.0.0.0", 8, "10.0.0.0", "10.255.255.255", "private use"],
@@ -27,6 +29,13 @@ export const BLOCKED_IPV4_RANGES = [
27
29
  ["240.0.0.0", 4, "240.0.0.0", "255.255.255.255", "reserved"],
28
30
  ] as const;
29
31
 
32
+ /**
33
+ * IPv6 address ranges that are blocked from network requests.
34
+ *
35
+ * Reviewed 2026-08-03 against the IANA IPv6 Special-Purpose Address Registry:
36
+ * https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
37
+ * Includes non-global, documentation, benchmarking, multicast, and reserved ranges.
38
+ */
30
39
  export const BLOCKED_IPV6_RANGES = [
31
40
  ["::", 128, "::", "::", "unspecified"],
32
41
  ["::1", 128, "::1", "::1", "loopback"],
@@ -68,6 +77,12 @@ for (const [network, prefix] of GLOBALLY_REACHABLE_IPV6_EXCEPTIONS) {
68
77
  allowedIPv6Addresses.addSubnet(network, prefix, "ipv6");
69
78
  }
70
79
 
80
+ /**
81
+ * A validated and resolved network target ready for connection.
82
+ *
83
+ * Contains the target URL, preferred address, and all resolved addresses
84
+ * for the hostname.
85
+ */
71
86
  export interface ValidatedTarget {
72
87
  url: URL;
73
88
  /** The preferred address used for the first connection attempt. */
@@ -80,6 +95,7 @@ export interface ValidatedTarget {
80
95
  addresses?: string[];
81
96
  }
82
97
 
98
+ /** Function type for resolving a hostname to IP addresses. */
83
99
  export type ResolveAddresses = (hostname: string) => Promise<string[]>;
84
100
 
85
101
  /**
@@ -3,33 +3,148 @@ import { awaitWithAbort } from "./abort";
3
3
  import { validateRemoteUrl, type ValidatedTarget } from "./network-policy";
4
4
  import { requestPinned, responseHeader } from "./network-transport";
5
5
 
6
+ /** Maximum number of HTTP redirects to follow before aborting. */
6
7
  export const FETCH_MAX_REDIRECTS = 5;
8
+ const MAX_ORIGIN_CONCURRENCY = 4;
9
+ const MAX_RETRIES = 2;
10
+ const MAX_RETRY_DELAY_MS = 10_000;
11
+ const BASE_RETRY_DELAY_MS = 250;
7
12
 
13
+ interface OriginState {
14
+ active: number;
15
+ waiting: Array<() => void>;
16
+ }
17
+ const origins = new Map<string, OriginState>();
18
+
19
+ /** Optional dependencies for redirect handling (used for testing). */
8
20
  export interface RedirectDependencies {
9
21
  validateUrl?: (value: string | URL) => Promise<ValidatedTarget>;
10
- request?: (target: ValidatedTarget, signal: AbortSignal) => Promise<IncomingMessage>;
22
+ request?: (
23
+ target: ValidatedTarget,
24
+ signal: AbortSignal,
25
+ headers?: Readonly<Record<string, string>>,
26
+ ) => Promise<IncomingMessage>;
27
+ random?: () => number;
28
+ sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
29
+ }
30
+
31
+ function abortError(): Error {
32
+ const error = new Error("Operation aborted.");
33
+ error.name = "AbortError";
34
+ return error;
35
+ }
36
+
37
+ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() => void> {
38
+ const state = origins.get(origin) ?? { active: 0, waiting: [] };
39
+ origins.set(origin, state);
40
+ let reserved = false;
41
+ const release = () => {
42
+ state.active -= 1;
43
+ state.waiting.shift()?.();
44
+ if (state.active === 0 && state.waiting.length === 0) origins.delete(origin);
45
+ };
46
+ if (state.active >= MAX_ORIGIN_CONCURRENCY) {
47
+ await new Promise<void>((resolve, reject) => {
48
+ const enter = () => {
49
+ signal.removeEventListener("abort", cancel);
50
+ state.active += 1;
51
+ reserved = true;
52
+ resolve();
53
+ };
54
+ const cancel = () => {
55
+ const index = state.waiting.indexOf(enter);
56
+ if (index >= 0) state.waiting.splice(index, 1);
57
+ reject(abortError());
58
+ };
59
+ if (signal.aborted) cancel();
60
+ else {
61
+ state.waiting.push(enter);
62
+ signal.addEventListener("abort", cancel, { once: true });
63
+ }
64
+ });
65
+ }
66
+ if (signal.aborted) {
67
+ if (reserved) release();
68
+ throw abortError();
69
+ }
70
+ if (!reserved) state.active += 1;
71
+ return release;
72
+ }
73
+
74
+ async function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
75
+ await new Promise<void>((resolve, reject) => {
76
+ const timer = setTimeout(done, milliseconds);
77
+ function done(): void {
78
+ signal.removeEventListener("abort", cancel);
79
+ resolve();
80
+ }
81
+ function cancel(): void {
82
+ clearTimeout(timer);
83
+ reject(abortError());
84
+ }
85
+ if (signal.aborted) cancel();
86
+ else signal.addEventListener("abort", cancel, { once: true });
87
+ });
88
+ }
89
+
90
+ function retryAfterMilliseconds(value: string | undefined, now = Date.now()): number | undefined {
91
+ if (!value) return undefined;
92
+ const seconds = Number(value.trim());
93
+ if (Number.isFinite(seconds) && seconds >= 0)
94
+ return Math.min(seconds * 1_000, MAX_RETRY_DELAY_MS);
95
+ const date = Date.parse(value);
96
+ if (!Number.isFinite(date)) return undefined;
97
+ return Math.min(Math.max(0, date - now), MAX_RETRY_DELAY_MS);
98
+ }
99
+
100
+ async function coordinatedRequest(
101
+ target: ValidatedTarget,
102
+ signal: AbortSignal,
103
+ headers: Readonly<Record<string, string>>,
104
+ dependencies: RedirectDependencies,
105
+ ): Promise<IncomingMessage> {
106
+ const request =
107
+ dependencies.request ??
108
+ ((value, requestSignal, requestHeaders) =>
109
+ requestPinned(value, requestSignal, { headers: requestHeaders }));
110
+ const random = dependencies.random ?? Math.random;
111
+ const sleep = dependencies.sleep ?? defaultSleep;
112
+ for (let attempt = 0; ; attempt += 1) {
113
+ const release = await acquireOrigin(target.url.origin, signal);
114
+ let response: IncomingMessage;
115
+ try {
116
+ response = await request(target, signal, headers);
117
+ } finally {
118
+ release();
119
+ }
120
+ const status = response.statusCode ?? 0;
121
+ if (![429, 503].includes(status) || attempt >= MAX_RETRIES) return response;
122
+ // The retry decision uses headers only. Drop an untrusted error body so it cannot
123
+ // consume unbounded bandwidth while the caller is backing off.
124
+ response.destroy();
125
+ const advertised = retryAfterMilliseconds(responseHeader(response, "retry-after"));
126
+ const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt;
127
+ const jittered = Math.round(exponential * (0.75 + Math.min(1, Math.max(0, random())) * 0.5));
128
+ await sleep(advertised ?? Math.min(jittered, MAX_RETRY_DELAY_MS), signal);
129
+ }
11
130
  }
12
131
 
13
132
  /**
14
- * Requests a URL and follows supported HTTP redirects.
15
- *
16
- * @param value - The initial URL to request
17
- * @param signal - Signal used to cancel validation and requests
18
- * @param dependencies - Optional URL-validation and request implementations
19
- * @returns The final validated target and its HTTP response
20
- * @throws If a redirect lacks a `Location` header or the redirect limit is exceeded
133
+ * Requests a URL and follows supported HTTP redirects. Every attempt, redirect, and retry
134
+ * remains inside URL validation, DNS pinning, origin coordination, and caller cancellation.
21
135
  */
22
136
  export async function requestFollowingRedirects(
23
137
  value: string | URL,
24
138
  signal: AbortSignal,
25
139
  dependencies: RedirectDependencies = {},
140
+ headers: Readonly<Record<string, string>> = {},
26
141
  ): Promise<{ target: ValidatedTarget; response: IncomingMessage }> {
27
142
  const validateUrl = dependencies.validateUrl ?? validateRemoteUrl;
28
- const request = dependencies.request ?? requestPinned;
29
143
  let target = await awaitWithAbort(validateUrl(value), signal);
144
+ let requestHeaders = headers;
30
145
 
31
146
  for (let redirects = 0; redirects <= FETCH_MAX_REDIRECTS; redirects += 1) {
32
- const response = await request(target, signal);
147
+ const response = await coordinatedRequest(target, signal, requestHeaders, dependencies);
33
148
  const status = response.statusCode ?? 0;
34
149
  if (![301, 302, 303, 307, 308].includes(status)) return { target, response };
35
150
 
@@ -39,6 +154,8 @@ export async function requestFollowingRedirects(
39
154
  throw new Error("web_fetch followed too many redirects.");
40
155
  response.resume();
41
156
  target = await awaitWithAbort(validateUrl(new URL(location, target.url)), signal);
157
+ // Validators describe the originally cached resource and must not leak to a redirect target.
158
+ requestHeaders = {};
42
159
  }
43
160
  throw new Error("web_fetch followed too many redirects.");
44
161
  }