@zeldrisho/pi-web-fetch 0.7.0 → 0.7.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.
- package/CHANGELOG.md +10 -1
- package/package.json +2 -2
- package/src/content.ts +24 -0
- package/src/extract.ts +80 -7
- package/src/fetch.ts +55 -2
- package/src/index.ts +8 -0
- package/src/limits.ts +11 -0
- package/src/network-policy.ts +21 -5
- package/src/network-redirects.ts +2 -0
- package/src/network-transport.ts +30 -2
- package/src/service.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.1] - 2026-08-28
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Prevent immediately unreachable pinned addresses from emitting uncaught socket errors and terminating Pi under concurrent fetch load
|
|
15
|
+
- Fix `https://github.com/voidzero-dev/vite-plus/releases` rendering in pi: harden `runDefuddle` to swallow bare-pathname `ERR_INVALID_URL` detached rejections, guarantee absolute `http(s)` URLs at every extraction/fetch boundary, strip `nav`/`header`/`footer`/`aside` chrome before Defuddle, deduplicate adjacent identical markdown links, and bump `defuddle` to `^0.19.3`
|
|
16
|
+
- Fix GitHub file fetches returning `https://github.com/llms.txt` instead of raw content: bypass `llms.txt` probing and `describedby`/`alternate` fallbacks for `github.com`, `raw.githubusercontent.com`, `gist.github.com` and `gist.githubusercontent.com`, and rewrite `github.com/*/tree/<ref>/*.*` file URLs to `raw.githubusercontent.com` like existing `blob` handling so `tree` file links (e.g. `.../permission-gate.ts`) return clean code
|
|
17
|
+
|
|
10
18
|
## [0.7.0] - 2026-08-24
|
|
11
19
|
|
|
12
20
|
### Added
|
|
@@ -102,7 +110,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
102
110
|
|
|
103
111
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
104
112
|
|
|
105
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.
|
|
113
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...HEAD
|
|
114
|
+
[0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.0...pi-web-fetch-v0.7.1
|
|
106
115
|
[0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.1...pi-web-fetch-v0.7.0
|
|
107
116
|
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.0...pi-web-fetch-v0.6.1
|
|
108
117
|
[0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.4...pi-web-fetch-v0.6.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"defuddle": "^0.19.
|
|
34
|
+
"defuddle": "^0.19.3",
|
|
35
35
|
"linkedom": "^0.18.13"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
package/src/content.ts
CHANGED
|
@@ -4,6 +4,12 @@ const CONTENT_LINE_BUDGET = Math.max(1, DEFAULT_MAX_LINES - 10);
|
|
|
4
4
|
const CONTENT_BYTE_BUDGET = Math.max(1_024, DEFAULT_MAX_BYTES - 2_048);
|
|
5
5
|
const encoder = new TextEncoder();
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A complete fetched document with extracted content and metadata.
|
|
9
|
+
*
|
|
10
|
+
* Contains the full markdown representation along with metadata about
|
|
11
|
+
* the extraction method and any special handling that was applied.
|
|
12
|
+
*/
|
|
7
13
|
export interface CompleteDocument {
|
|
8
14
|
url: string;
|
|
9
15
|
contentType: string;
|
|
@@ -24,6 +30,12 @@ export interface CompleteDocument {
|
|
|
24
30
|
markdownAlternateFallback?: boolean;
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A paginated chunk of fetched content with offset tracking.
|
|
35
|
+
*
|
|
36
|
+
* Extends CompleteDocument with pagination metadata to support
|
|
37
|
+
* reading large documents in bounded chunks.
|
|
38
|
+
*/
|
|
27
39
|
export interface FetchResult extends CompleteDocument {
|
|
28
40
|
offset: number;
|
|
29
41
|
nextOffset?: number;
|
|
@@ -55,6 +67,18 @@ function boundedContentChunk(value: string, offset: number, maxCharacters: numbe
|
|
|
55
67
|
return sliceByByteLength(chunk, CONTENT_BYTE_BUDGET);
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Slice a complete document into a bounded content chunk.
|
|
72
|
+
*
|
|
73
|
+
* Extracts a chunk of content starting at the given offset, respecting
|
|
74
|
+
* character, line, and byte limits. Adds pagination metadata and
|
|
75
|
+
* continuation instructions when content is truncated.
|
|
76
|
+
*
|
|
77
|
+
* @param document - Complete document to slice
|
|
78
|
+
* @param offset - Character offset to start reading from
|
|
79
|
+
* @param maxCharacters - Maximum characters to include in the chunk
|
|
80
|
+
* @returns Paginated fetch result with content chunk and metadata
|
|
81
|
+
*/
|
|
58
82
|
export function sliceCompleteDocument(
|
|
59
83
|
document: CompleteDocument,
|
|
60
84
|
offset: number,
|
package/src/extract.ts
CHANGED
|
@@ -76,6 +76,78 @@ export function htmlToMarkdownFallback(html: string): string {
|
|
|
76
76
|
.trim();
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Asserts that a URL is absolute http(s) and returns it, or throws for bare pathnames. */
|
|
80
|
+
export function assertAbsoluteHttpUrl(value: string | URL): URL {
|
|
81
|
+
const url = value instanceof URL ? value : new URL(value);
|
|
82
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
83
|
+
throw new TypeError(`Expected absolute http(s) URL, got ${url.protocol}: ${url.href}`);
|
|
84
|
+
}
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Type guard that checks if an unknown value is a string.
|
|
90
|
+
*
|
|
91
|
+
* @param value - The value to check
|
|
92
|
+
* @returns `true` if the value is a string
|
|
93
|
+
*/
|
|
94
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows unknown at call site
|
|
95
|
+
function isString(value: unknown): value is string {
|
|
96
|
+
return typeof value === "string";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Returns true when a rejection looks like `ERR_INVALID_URL` for a bare pathname. */
|
|
100
|
+
function isBarePathnameInvalidUrl(cause: unknown): boolean {
|
|
101
|
+
if (!(cause instanceof Error)) return false;
|
|
102
|
+
if (!/Invalid URL/i.test(cause.message)) return false;
|
|
103
|
+
// SAFETY: Node's ERR_INVALID_URL extends Error with an `input` string property holding the offending URL.
|
|
104
|
+
const input = (cause as NodeJS.ErrnoException & { input?: unknown }).input;
|
|
105
|
+
if (isString(input) && input.startsWith("/")) return true;
|
|
106
|
+
// SAFETY: Error.stack is an optional string populated by V8; we coerce it for pattern matching.
|
|
107
|
+
const detail = `${cause.message}\n${(cause as Error).stack ?? ""}`;
|
|
108
|
+
// Node's ERR_INVALID_URL prints `input: '/path'` in the message/stack even when
|
|
109
|
+
// `input` is not on the error instance in some Node versions.
|
|
110
|
+
return /input:\s*'\/[^']*'/.test(detail);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Removes chrome wrappers that pollute Defuddle's article extraction on GitHub releases.
|
|
115
|
+
*
|
|
116
|
+
* @param document - The document from which to remove chrome wrappers
|
|
117
|
+
* @param baseUrl - The absolute URL of the page, used to determine if chrome should be stripped
|
|
118
|
+
*/
|
|
119
|
+
function stripChromeWrappers(document: Document, baseUrl: URL): void {
|
|
120
|
+
// Only strip chrome wrappers for GitHub release pages
|
|
121
|
+
const isGitHubRelease =
|
|
122
|
+
baseUrl.hostname === "github.com" && /\/releases(?:\/|$)/.test(baseUrl.pathname);
|
|
123
|
+
if (!isGitHubRelease) return;
|
|
124
|
+
for (const element of document.querySelectorAll("nav, header, footer, aside")) {
|
|
125
|
+
element.remove();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Collapses consecutive duplicate markdown link lines (e.g. duplicated [Latest] on GitHub releases).
|
|
131
|
+
*
|
|
132
|
+
* @param markdown - The markdown string to process
|
|
133
|
+
* @returns The markdown with adjacent duplicate links removed
|
|
134
|
+
*/
|
|
135
|
+
function deduplicateAdjacentLinks(markdown: string): string {
|
|
136
|
+
const lines = markdown.split("\n");
|
|
137
|
+
const out: string[] = [];
|
|
138
|
+
let previousTrimmed: string | undefined;
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const trimmed = line.trim();
|
|
141
|
+
const isLinkLine =
|
|
142
|
+
/^\[[^\]]+\]\(<[^>]+>\)$/.test(trimmed) || /^\[[^\]]+\]\([^)]+\)$/.test(trimmed);
|
|
143
|
+
if (isLinkLine && trimmed === previousTrimmed) continue;
|
|
144
|
+
out.push(line);
|
|
145
|
+
previousTrimmed = isLinkLine ? trimmed : undefined;
|
|
146
|
+
}
|
|
147
|
+
// Also collapse duplicate adjacent blocks like a repeated vite-plus tag link paragraph.
|
|
148
|
+
return out.join("\n").replace(/(\[[^\n]+?\([^)]+\)[^\n]*\n)\1/g, "$1");
|
|
149
|
+
}
|
|
150
|
+
|
|
79
151
|
/**
|
|
80
152
|
* Runs Defuddle over an already-normalized document with Markdown extraction enabled.
|
|
81
153
|
*
|
|
@@ -94,9 +166,10 @@ export function htmlToMarkdownFallback(html: string): string {
|
|
|
94
166
|
* To keep `extractHtmlToMarkdown` from ever propagating such a failure, this
|
|
95
167
|
* helper arms a scoped `unhandledRejection` listener for the lifetime of the
|
|
96
168
|
* call. The listener is scoped, not process-wide in effect: it only treats a
|
|
97
|
-
* rejection as a Defuddle failure when its message or stack mentions Defuddle
|
|
98
|
-
*
|
|
99
|
-
*
|
|
169
|
+
* rejection as a Defuddle failure when its message or stack mentions Defuddle
|
|
170
|
+
* or looks like a bare-pathname `ERR_INVALID_URL` occurring within the armed
|
|
171
|
+
* window, so unrelated rejections from other concurrent work are ignored and do
|
|
172
|
+
* not force a spurious fallback to the basic extractor. After Defuddle resolves we
|
|
100
173
|
* flush a microtask and a macrotask so any rejection Defuddle scheduled settles
|
|
101
174
|
* inside the armed window; a rejection observed there is re-thrown so the
|
|
102
175
|
* caller falls back. Deeply-nested timers in Defuddle are out of scope and would
|
|
@@ -112,13 +185,11 @@ async function runDefuddle(
|
|
|
112
185
|
): Promise<DefuddleResponse | undefined> {
|
|
113
186
|
let escapedRejection: unknown = undefined;
|
|
114
187
|
let armed = true;
|
|
115
|
-
// Only attribute a rejection to Defuddle when it mentions Defuddle. This keeps
|
|
116
|
-
// the guard scoped so unrelated concurrent rejections are ignored.
|
|
117
188
|
const captureUnhandled = (cause: unknown): void => {
|
|
118
189
|
if (!armed || escapedRejection !== undefined) return;
|
|
119
190
|
const detail =
|
|
120
191
|
cause instanceof Error ? `${cause.message}\n${cause.stack ?? ""}` : String(cause);
|
|
121
|
-
if (/defuddle/i.test(detail)) escapedRejection = cause;
|
|
192
|
+
if (/defuddle/i.test(detail) || isBarePathnameInvalidUrl(cause)) escapedRejection = cause;
|
|
122
193
|
};
|
|
123
194
|
process.on("unhandledRejection", captureUnhandled);
|
|
124
195
|
try {
|
|
@@ -200,15 +271,17 @@ export async function extractHtmlToMarkdown(
|
|
|
200
271
|
}> {
|
|
201
272
|
let advertised: AdvertisedLinks = {};
|
|
202
273
|
try {
|
|
274
|
+
assertAbsoluteHttpUrl(baseUrl);
|
|
203
275
|
const { document } = parseHTML(html);
|
|
204
276
|
removeMalformedSchemaOrgData(document);
|
|
205
277
|
normalizeSelectorUnsafeIds(document);
|
|
278
|
+
stripChromeWrappers(document, baseUrl);
|
|
206
279
|
advertised = readAdvertisedLinks(document, baseUrl);
|
|
207
280
|
// Pass the full absolute URL so Defuddle resolves relative links (e.g.
|
|
208
281
|
// `/owner/repo/releases`) and metadata against the real origin instead of
|
|
209
282
|
// dropping the scheme and host and constructing `new URL(pathname)`.
|
|
210
283
|
const result = await runDefuddle(document, baseUrl.href);
|
|
211
|
-
const markdown = result?.content?.trim() ?? "";
|
|
284
|
+
const markdown = deduplicateAdjacentLinks(result?.content?.trim() ?? "");
|
|
212
285
|
const trimmedTitle = result?.title?.trim();
|
|
213
286
|
if (markdown) {
|
|
214
287
|
return {
|
package/src/fetch.ts
CHANGED
|
@@ -27,8 +27,21 @@ export function normalizeGitHubRawUrl(rawUrl: string): string {
|
|
|
27
27
|
const url = new URL(rawUrl);
|
|
28
28
|
if (url.protocol !== "https:") return rawUrl;
|
|
29
29
|
if (url.hostname === "github.com") {
|
|
30
|
-
if (
|
|
31
|
-
|
|
30
|
+
if (url.pathname.includes("/blob/")) {
|
|
31
|
+
return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
|
|
32
|
+
}
|
|
33
|
+
// Parse pathname segments: /owner/repo/tree/ref/path
|
|
34
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
35
|
+
if (segments.length >= 4 && segments[2] === "tree") {
|
|
36
|
+
const lastSegment = segments[segments.length - 1];
|
|
37
|
+
if (lastSegment.includes(".")) {
|
|
38
|
+
// Reconstruct path: /owner/repo/ref/path (remove "tree" segment)
|
|
39
|
+
const newPath = `/${segments[0]}/${segments[1]}/${segments.slice(3).join("/")}`;
|
|
40
|
+
return `https://raw.githubusercontent.com${newPath}${url.search}`;
|
|
41
|
+
}
|
|
42
|
+
return rawUrl;
|
|
43
|
+
}
|
|
44
|
+
return rawUrl;
|
|
32
45
|
}
|
|
33
46
|
if (url.hostname === "gist.github.com") {
|
|
34
47
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
@@ -242,11 +255,41 @@ async function documentFromResponse(
|
|
|
242
255
|
};
|
|
243
256
|
}
|
|
244
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Validates that a URL string is an absolute http or https URL.
|
|
260
|
+
*
|
|
261
|
+
* @param value - The URL string to validate
|
|
262
|
+
* @returns The parsed URL object
|
|
263
|
+
* @throws If the URL is invalid or not http(s)
|
|
264
|
+
*/
|
|
265
|
+
function assertAbsoluteHttpUrlForFetch(value: string): URL {
|
|
266
|
+
let url: URL;
|
|
267
|
+
try {
|
|
268
|
+
url = new URL(value);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new Error(`Invalid URL: ${value}`);
|
|
271
|
+
}
|
|
272
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
273
|
+
throw new Error(`web_fetch only supports http(s) URLs: ${value}`);
|
|
274
|
+
}
|
|
275
|
+
return url;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Fetches a URL and returns the complete document with extracted content.
|
|
280
|
+
*
|
|
281
|
+
* @param rawUrl - The URL to fetch
|
|
282
|
+
* @param signal - Optional abort signal to cancel the request
|
|
283
|
+
* @param dependencies - Dependencies for network policy validation, transport, and HTML extraction
|
|
284
|
+
* @returns The complete document with extracted markdown content
|
|
285
|
+
* @throws If the request times out, is cancelled, or encounters an error
|
|
286
|
+
*/
|
|
245
287
|
export async function fetchCompleteDocument(
|
|
246
288
|
rawUrl: string,
|
|
247
289
|
signal: AbortSignal | undefined,
|
|
248
290
|
dependencies: FetchRemoteDependencies,
|
|
249
291
|
): Promise<CompleteDocument> {
|
|
292
|
+
assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
|
|
250
293
|
const controller = new AbortController();
|
|
251
294
|
const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
252
295
|
const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
|
|
@@ -278,6 +321,16 @@ export async function fetchCompleteDocument(
|
|
|
278
321
|
}
|
|
279
322
|
}
|
|
280
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Fetches a URL and returns a slice of the content starting at the given offset.
|
|
326
|
+
*
|
|
327
|
+
* @param rawUrl - The URL to fetch
|
|
328
|
+
* @param offset - The character offset to start slicing from
|
|
329
|
+
* @param maxCharacters - The maximum number of characters to return
|
|
330
|
+
* @param signal - Optional abort signal to cancel the request
|
|
331
|
+
* @param dependencies - Dependencies for network policy validation, transport, and HTML extraction
|
|
332
|
+
* @returns The sliced fetch result with content and metadata
|
|
333
|
+
*/
|
|
281
334
|
export async function fetchRemoteContent(
|
|
282
335
|
rawUrl: string,
|
|
283
336
|
offset: number,
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,14 @@ export const webFetchParameters = Type.Object({
|
|
|
51
51
|
),
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Pi web fetch extension that registers the web_fetch tool.
|
|
56
|
+
*
|
|
57
|
+
* Provides a tool for fetching public HTTP(S) pages and converting them to
|
|
58
|
+
* Markdown with support for pagination via offset/nextOffset parameters.
|
|
59
|
+
*
|
|
60
|
+
* @param pi - The extension API instance
|
|
61
|
+
*/
|
|
54
62
|
export default function (pi: ExtensionAPI) {
|
|
55
63
|
pi.registerTool({
|
|
56
64
|
name: "web_fetch",
|
package/src/limits.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
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 input URLs (characters). */
|
|
6
17
|
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,8 +3,10 @@ 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;
|
|
7
8
|
|
|
9
|
+
/** Optional dependencies for redirect handling (used for testing). */
|
|
8
10
|
export interface RedirectDependencies {
|
|
9
11
|
validateUrl?: (value: string | URL) => Promise<ValidatedTarget>;
|
|
10
12
|
request?: (target: ValidatedTarget, signal: AbortSignal) => Promise<IncomingMessage>;
|
package/src/network-transport.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isIP, type LookupFunction } from "node:net";
|
|
|
4
4
|
import { formatSize } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { ValidatedTarget } from "./network-policy";
|
|
6
6
|
|
|
7
|
+
/** Maximum size in bytes for raw HTTP response bodies. */
|
|
7
8
|
export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -63,8 +64,13 @@ async function requestOnce(
|
|
|
63
64
|
const family = isIP(address);
|
|
64
65
|
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
|
|
65
66
|
const lookup: LookupFunction = (_hostname, options, callback) => {
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Match dns.lookup's asynchronous callback contract. Calling back inline
|
|
68
|
+
// lets an immediately unreachable address emit a socket error before
|
|
69
|
+
// node:http has finished installing its forwarding listeners.
|
|
70
|
+
queueMicrotask(() => {
|
|
71
|
+
if (options.all) callback(null, [{ address, family }]);
|
|
72
|
+
else callback(null, address, family);
|
|
73
|
+
});
|
|
68
74
|
};
|
|
69
75
|
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
70
76
|
return await new Promise((resolve, reject) => {
|
|
@@ -161,6 +167,18 @@ export function responseHeader(response: IncomingMessage, name: string): string
|
|
|
161
167
|
return Array.isArray(value) ? value[0] : value;
|
|
162
168
|
}
|
|
163
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Read and buffer the complete response body.
|
|
172
|
+
*
|
|
173
|
+
* Enforces the maximum byte limit and respects abort signals during the read.
|
|
174
|
+
* Destroys the response stream if limits are exceeded or abort is signaled.
|
|
175
|
+
*
|
|
176
|
+
* @param response - HTTP response to read
|
|
177
|
+
* @param maxBytes - Maximum allowed response size in bytes
|
|
178
|
+
* @param signal - Optional abort signal to cancel the read
|
|
179
|
+
* @returns Buffered response body as bytes
|
|
180
|
+
* @throws When response exceeds maxBytes or abort signal fires
|
|
181
|
+
*/
|
|
164
182
|
export async function readResponseBytes(
|
|
165
183
|
response: IncomingMessage,
|
|
166
184
|
maxBytes: number,
|
|
@@ -208,6 +226,16 @@ export async function readResponseBytes(
|
|
|
208
226
|
return output;
|
|
209
227
|
}
|
|
210
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Decode HTTP response bytes to a string using the charset from Content-Type.
|
|
231
|
+
*
|
|
232
|
+
* Extracts the charset parameter from the Content-Type header and uses it
|
|
233
|
+
* to decode the response. Falls back to UTF-8 if charset is missing or invalid.
|
|
234
|
+
*
|
|
235
|
+
* @param bytes - Raw response bytes to decode
|
|
236
|
+
* @param contentTypeHeader - Value of the Content-Type response header
|
|
237
|
+
* @returns Decoded response string
|
|
238
|
+
*/
|
|
211
239
|
export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
212
240
|
const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
|
|
213
241
|
try {
|
package/src/service.ts
CHANGED
|
@@ -47,6 +47,17 @@ function safeUrl(value: string): URL | undefined {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Classify the kind of content returned by a fetch operation.
|
|
52
|
+
*
|
|
53
|
+
* Determines the content type based on URL patterns, extractor used,
|
|
54
|
+
* and whether an app shell was detected.
|
|
55
|
+
*
|
|
56
|
+
* @param url - The fetched URL
|
|
57
|
+
* @param extractor - Extraction method used
|
|
58
|
+
* @param shellSuspected - Whether the page appears to be an app shell
|
|
59
|
+
* @returns Content classification
|
|
60
|
+
*/
|
|
50
61
|
export function classifyContentKind(
|
|
51
62
|
url: string,
|
|
52
63
|
extractor: CompleteDocument["extractor"],
|
|
@@ -69,6 +80,17 @@ export function classifyContentKind(
|
|
|
69
80
|
return "unknown";
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Classify confidence level for fetched content quality.
|
|
85
|
+
*
|
|
86
|
+
* Determines how confident we are that the extracted content accurately
|
|
87
|
+
* represents the source page, based on extraction method and content length.
|
|
88
|
+
*
|
|
89
|
+
* @param extractor - Extraction method used
|
|
90
|
+
* @param shellSuspected - Whether the page appears to be an app shell
|
|
91
|
+
* @param markdownLength - Length of extracted markdown content
|
|
92
|
+
* @returns Confidence level (high, medium, or low)
|
|
93
|
+
*/
|
|
72
94
|
export function classifyConfidence(
|
|
73
95
|
extractor: CompleteDocument["extractor"],
|
|
74
96
|
shellSuspected: boolean,
|
|
@@ -87,10 +109,30 @@ export function classifyConfidence(
|
|
|
87
109
|
* Returns them ordered shallow → deep, and an empty list for non-HTTP(S) targets or when
|
|
88
110
|
* the page itself is already a `llms.txt` path, so a fallback never retries itself.
|
|
89
111
|
*/
|
|
112
|
+
function isGitHubLikeHost(hostname: string): boolean {
|
|
113
|
+
return (
|
|
114
|
+
hostname === "github.com" ||
|
|
115
|
+
hostname === "raw.githubusercontent.com" ||
|
|
116
|
+
hostname === "gist.github.com" ||
|
|
117
|
+
hostname === "gist.githubusercontent.com"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build candidate /llms.txt URLs to probe for a given page.
|
|
123
|
+
*
|
|
124
|
+
* Generates a list of potential /llms.txt index URLs by checking the site root
|
|
125
|
+
* and ancestor directories up to a maximum depth. Returns URLs ordered from
|
|
126
|
+
* shallowest to deepest.
|
|
127
|
+
*
|
|
128
|
+
* @param rawUrl - The original page URL
|
|
129
|
+
* @returns Array of candidate /llms.txt URLs to probe (empty for non-HTTP/S or GitHub)
|
|
130
|
+
*/
|
|
90
131
|
export function buildLlmsTxtCandidateUrls(rawUrl: string): URL[] {
|
|
91
132
|
try {
|
|
92
133
|
const url = new URL(rawUrl);
|
|
93
134
|
if (url.protocol !== "https:" && url.protocol !== "http:") return [];
|
|
135
|
+
if (isGitHubLikeHost(url.hostname)) return [];
|
|
94
136
|
if (url.pathname === "/llms.txt" || url.pathname.endsWith("/llms.txt")) return [];
|
|
95
137
|
// Ancestor directories only, one level deep: for `/r2/buckets/x` probe
|
|
96
138
|
// `/llms.txt` and `/r2/llms.txt`, never deeper. A trailing slash marks the final
|
|
@@ -232,6 +274,13 @@ async function fetchDocumentWithLlmsTxtSupport(
|
|
|
232
274
|
signal: AbortSignal | undefined,
|
|
233
275
|
dependencies: FetchRemoteDependencies,
|
|
234
276
|
): Promise<CompleteDocument> {
|
|
277
|
+
try {
|
|
278
|
+
if (isGitHubLikeHost(new URL(rawUrl).hostname)) {
|
|
279
|
+
return fetchCompleteDocument(rawUrl, signal, dependencies);
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
// Invalid URL falls through to fetchCompleteDocument which will throw.
|
|
283
|
+
}
|
|
235
284
|
const candidates = buildLlmsTxtCandidateUrls(rawUrl);
|
|
236
285
|
const [primary, blindIndex] = await Promise.all([
|
|
237
286
|
fetchCompleteDocument(rawUrl, signal, dependencies),
|
|
@@ -277,18 +326,21 @@ function resolveCacheDirectory(name: string): string {
|
|
|
277
326
|
return base;
|
|
278
327
|
}
|
|
279
328
|
|
|
329
|
+
/** Parameters for a web fetch operation. */
|
|
280
330
|
export interface WebFetchParameters {
|
|
281
331
|
url: string;
|
|
282
332
|
offset?: number;
|
|
283
333
|
maxCharacters?: number;
|
|
284
334
|
}
|
|
285
335
|
|
|
336
|
+
/** Details about content truncation and pagination strategy. */
|
|
286
337
|
export interface WebFetchTruncationDetails {
|
|
287
338
|
truncated: boolean;
|
|
288
339
|
strategy: "continuation" | "none";
|
|
289
340
|
nextOffset?: number;
|
|
290
341
|
}
|
|
291
342
|
|
|
343
|
+
/** Comprehensive metadata about a web fetch result. */
|
|
292
344
|
export interface WebFetchDetails {
|
|
293
345
|
url: string;
|
|
294
346
|
requestedUrl: string;
|