@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/CHANGELOG.md +30 -1
- package/README.md +12 -2
- package/package.json +2 -2
- package/src/cache.ts +36 -0
- package/src/content.ts +34 -1
- package/src/evidence.ts +118 -0
- package/src/extract.ts +214 -8
- package/src/fetch.ts +114 -43
- package/src/focus.ts +125 -0
- package/src/index.ts +27 -0
- package/src/inflight.ts +22 -0
- package/src/limits.ts +14 -0
- package/src/network-policy.ts +21 -5
- package/src/network-redirects.ts +127 -10
- package/src/network-transport.ts +35 -3
- package/src/outline.ts +240 -0
- package/src/service.ts +138 -16
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 (
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
request: dependencies.request,
|
|
268
|
-
},
|
|
293
|
+
dependencies,
|
|
294
|
+
conditionalHeaders,
|
|
269
295
|
);
|
|
270
|
-
|
|
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;
|
package/src/network-policy.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
/**
|
package/src/network-redirects.ts
CHANGED
|
@@ -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?: (
|
|
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
|
|
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
|
}
|