@zeldrisho/pi-web-fetch 0.5.2 → 0.5.4
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 +18 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/fetch.ts +4 -1
- package/src/network-policy.ts +32 -2
- package/src/network-transport.ts +139 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.4](https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.3...pi-web-fetch-v0.5.4) (2026-08-12)
|
|
4
|
+
|
|
5
|
+
### Bug fixes
|
|
6
|
+
|
|
7
|
+
- **web-fetch:** Abort stalled response bodies instead of hanging ([0b0a080](https://github.com/zeldrisho/pi-packages/commit/0b0a080b6231944c3d7788af1a4710c3ca0cee78))
|
|
8
|
+
- Apply CodeRabbit auto-fixes ([ddaa177](https://github.com/zeldrisho/pi-packages/commit/ddaa17764aedf0127c4057d3abb6900acfab2b78))
|
|
9
|
+
|
|
10
|
+
## [0.5.3](https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.2...pi-web-fetch-v0.5.3) (2026-08-12)
|
|
11
|
+
|
|
12
|
+
### Bug fixes
|
|
13
|
+
|
|
14
|
+
- **web-fetch:** Fall back across validated addresses before timing out ([32a000b](https://github.com/zeldrisho/pi-packages/commit/32a000bf367268d4ff38e7b45cb73d45ea894f5d))
|
|
15
|
+
- **web-fetch:** Cancel attempts when the caller signal is already aborted ([2f62c95](https://github.com/zeldrisho/pi-packages/commit/2f62c95906e171cc974c9f5cdbd66cf3ecd65b56))
|
|
16
|
+
|
|
17
|
+
### Maintenance
|
|
18
|
+
|
|
19
|
+
- **deps:** Upgrade Vite+ toolchain to 0.2.9 ([9921cf3](https://github.com/zeldrisho/pi-packages/commit/9921cf3ffbed29f9c08ca3ab595a5096fadf2be0))
|
|
20
|
+
|
|
3
21
|
## [0.5.2](https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.1...pi-web-fetch-v0.5.2) (2026-08-10)
|
|
4
22
|
|
|
5
23
|
### Bug fixes
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ The `web_fetch` tool accepts public HTTP and HTTPS URLs. It supports textual con
|
|
|
20
20
|
|
|
21
21
|
For safety, the tool blocks URLs containing credentials, local hostnames, private or reserved network targets, unsafe redirects, raw responses larger than 5 MiB, and unsupported content types. The `maxCharacters` parameter controls returned Markdown length; it does not change the raw download limit.
|
|
22
22
|
|
|
23
|
-
In Pi's interactive UI, fetched content uses Pi's standard collapsed preview; use the configured tool-expansion shortcut (`Ctrl+O` by default) to show all visible tool output. Output sent to the agent remains bounded. The `offset` parameter is a character offset into extracted content, not a byte range into the remote response. When a result is truncated, call the tool again with the returned `nextOffset` as `offset` to continue reading. Fetched and extracted pages are cached in byte-bounded memory for a limited time so continuation requests can reuse the same content. Concurrent requests for the same URL share one fetch; cancelling one caller does not cancel work still needed by another.
|
|
23
|
+
In Pi's interactive UI, fetched content uses Pi's standard collapsed preview; use the configured tool-expansion shortcut (`Ctrl+O` by default) to show all visible tool output. Output sent to the agent remains bounded: each call returns at most `maxCharacters` characters of extracted Markdown (default 6,000) and is additionally capped by Pi's 2,000-line / 50 KiB tool-output limit, so fetching cannot bloat the conversation context. The `offset` parameter is a character offset into extracted content, not a byte range into the remote response. When a result is truncated, call the tool again with the returned `nextOffset` as `offset` to continue reading. Fetched and extracted pages are cached in byte-bounded memory for a limited time so continuation requests can reuse the same content. Concurrent requests for the same URL share one fetch; cancelling one caller does not cancel work still needed by another.
|
|
24
24
|
|
|
25
25
|
Every result includes `details.truncation`. Complete output reports `{ truncated: false, strategy: "none" }`. Truncated output reports `strategy: "continuation"` and a valid `nextOffset`. The existing top-level `details.truncated` and `details.nextOffset` fields remain available.
|
|
26
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
40
40
|
"typebox": "^1.1.24",
|
|
41
41
|
"typescript": "^7.0.0",
|
|
42
|
-
"vite
|
|
42
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.9",
|
|
43
|
+
"vite-plus": "0.2.9"
|
|
43
44
|
},
|
|
44
45
|
"peerDependencies": {
|
|
45
46
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/fetch.ts
CHANGED
|
@@ -60,7 +60,10 @@ async function documentFromResponse(
|
|
|
60
60
|
throw new Error(`web_fetch does not support ${contentType || "this content type"}.`);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const raw = decodeResponse(
|
|
63
|
+
const raw = decodeResponse(
|
|
64
|
+
await readResponseBytes(response, FETCH_MAX_BYTES, signal),
|
|
65
|
+
contentTypeHeader,
|
|
66
|
+
);
|
|
64
67
|
let markdown: string;
|
|
65
68
|
let title: string | undefined;
|
|
66
69
|
let extractor: CompleteDocument["extractor"] = "raw";
|
package/src/network-policy.ts
CHANGED
|
@@ -70,12 +70,24 @@ for (const [network, prefix] of GLOBALLY_REACHABLE_IPV6_EXCEPTIONS) {
|
|
|
70
70
|
|
|
71
71
|
export interface ValidatedTarget {
|
|
72
72
|
url: URL;
|
|
73
|
+
/** The preferred address used for the first connection attempt. */
|
|
73
74
|
address: string;
|
|
74
75
|
family: 4 | 6;
|
|
76
|
+
/**
|
|
77
|
+
* Every address resolved for the hostname, ordered for connection attempts.
|
|
78
|
+
* Absent for single-address targets constructed without DNS resolution.
|
|
79
|
+
*/
|
|
80
|
+
addresses?: string[];
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
export type ResolveAddresses = (hostname: string) => Promise<string[]>;
|
|
78
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Determines whether an IP address belongs to a blocked or reserved address range.
|
|
87
|
+
*
|
|
88
|
+
* @param address - The IP address to evaluate
|
|
89
|
+
* @returns `true` if the address is invalid or blocked, `false` if it is allowed
|
|
90
|
+
*/
|
|
79
91
|
export function isPrivateAddress(address: string): boolean {
|
|
80
92
|
const family = isIP(address);
|
|
81
93
|
if (family === 4) return blockedIPv4Addresses.check(address, "ipv4");
|
|
@@ -86,6 +98,23 @@ export function isPrivateAddress(address: string): boolean {
|
|
|
86
98
|
return true;
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Orders addresses with IPv4 addresses before IPv6 addresses while preserving their original order within each family.
|
|
103
|
+
*
|
|
104
|
+
* @returns A copy of the addresses ordered with IPv4 addresses first.
|
|
105
|
+
*/
|
|
106
|
+
export function preferIpv4First(addresses: string[]): string[] {
|
|
107
|
+
return [...addresses].sort((a, b) => Number(isIP(b) === 4) - Number(isIP(a) === 4));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validates an HTTP or HTTPS URL and resolves it to an allowed network target.
|
|
112
|
+
*
|
|
113
|
+
* @param value - The URL to validate.
|
|
114
|
+
* @param resolveHostname - Optional hostname resolver.
|
|
115
|
+
* @returns The validated URL, preferred connection address, address family, and ordered resolved addresses.
|
|
116
|
+
* @throws If the URL uses an unsupported scheme, contains credentials, targets a local hostname, resolves to a private or reserved address, or cannot be resolved to IPv4 or IPv6.
|
|
117
|
+
*/
|
|
89
118
|
export async function validateRemoteUrl(
|
|
90
119
|
value: string | URL,
|
|
91
120
|
resolveHostname?: ResolveAddresses,
|
|
@@ -115,8 +144,9 @@ export async function validateRemoteUrl(
|
|
|
115
144
|
if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
|
|
116
145
|
throw new Error(`web_fetch blocks private or reserved network targets (${hostname}).`);
|
|
117
146
|
}
|
|
118
|
-
const
|
|
147
|
+
const ordered = preferIpv4First(addresses);
|
|
148
|
+
const address = ordered[0];
|
|
119
149
|
const family = isIP(address);
|
|
120
150
|
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${hostname}.`);
|
|
121
|
-
return { url, address, family };
|
|
151
|
+
return { url, address, family, addresses: ordered };
|
|
122
152
|
}
|
package/src/network-transport.ts
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
import { request as httpRequest, type IncomingMessage } from "node:http";
|
|
2
2
|
import { request as httpsRequest } from "node:https";
|
|
3
|
-
import type
|
|
3
|
+
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
7
|
export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Per-address connect deadline. A hanging address is abandoned after this
|
|
11
|
+
* budget so the next validated address can be tried inside the overall
|
|
12
|
+
* request timeout.
|
|
13
|
+
*/
|
|
14
|
+
export const CONNECT_ATTEMPT_TIMEOUT_MS = 4_000;
|
|
15
|
+
|
|
9
16
|
const encoder = new TextEncoder();
|
|
10
17
|
|
|
18
|
+
/** Builds an AbortError matching the DOMException name used by the abort signal. */
|
|
19
|
+
function abortedError(): Error {
|
|
20
|
+
const error = new Error("Operation aborted.");
|
|
21
|
+
error.name = "AbortError";
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Formats an error message for a response that exceeds the raw download limit.
|
|
27
|
+
*
|
|
28
|
+
* @param receivedBytes - The number of bytes received or reported by the response.
|
|
29
|
+
* @param maxBytes - The maximum allowed number of bytes.
|
|
30
|
+
* @param sizeIsExact - Whether `receivedBytes` is the exact response size.
|
|
31
|
+
* @returns A message describing the response size and configured limit.
|
|
32
|
+
*/
|
|
11
33
|
function responseTooLargeMessage(
|
|
12
34
|
receivedBytes: number,
|
|
13
35
|
maxBytes: number,
|
|
@@ -22,33 +44,118 @@ function responseTooLargeMessage(
|
|
|
22
44
|
].join(" ");
|
|
23
45
|
}
|
|
24
46
|
|
|
25
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Sends a request to a specific validated network address.
|
|
49
|
+
*
|
|
50
|
+
* @param target - The validated request target.
|
|
51
|
+
* @param address - The IPv4 or IPv6 address to use for the connection.
|
|
52
|
+
* @param signal - Signal used to cancel the request.
|
|
53
|
+
* @param attemptTimeoutMs - Maximum time allowed for the connection attempt.
|
|
54
|
+
* @returns The received response.
|
|
55
|
+
* @throws If `address` is not a valid IPv4 or IPv6 address.
|
|
56
|
+
*/
|
|
57
|
+
async function requestOnce(
|
|
26
58
|
target: ValidatedTarget,
|
|
59
|
+
address: string,
|
|
27
60
|
signal: AbortSignal,
|
|
61
|
+
attemptTimeoutMs: number,
|
|
28
62
|
): Promise<IncomingMessage> {
|
|
63
|
+
const family = isIP(address);
|
|
64
|
+
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
|
|
29
65
|
const lookup: LookupFunction = (_hostname, options, callback) => {
|
|
30
|
-
if (options.all) callback(null, [{ address
|
|
31
|
-
else callback(null,
|
|
66
|
+
if (options.all) callback(null, [{ address, family }]);
|
|
67
|
+
else callback(null, address, family);
|
|
32
68
|
};
|
|
33
69
|
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
34
70
|
return await new Promise((resolve, reject) => {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
let attemptExpired = false;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
attemptExpired = true;
|
|
75
|
+
controller.abort();
|
|
76
|
+
}, attemptTimeoutMs);
|
|
77
|
+
const forwardAbort = () => controller.abort();
|
|
78
|
+
if (signal.aborted) controller.abort();
|
|
79
|
+
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
80
|
+
let settled = false;
|
|
81
|
+
const finish = (callback: () => void) => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
settled = true;
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
signal.removeEventListener("abort", forwardAbort);
|
|
86
|
+
callback();
|
|
87
|
+
};
|
|
88
|
+
const unreachableError = () =>
|
|
89
|
+
new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`);
|
|
35
90
|
const outgoing = request(
|
|
36
91
|
target.url,
|
|
37
92
|
{
|
|
38
93
|
lookup,
|
|
39
|
-
signal,
|
|
94
|
+
signal: controller.signal,
|
|
40
95
|
headers: {
|
|
41
96
|
Accept: "text/markdown, text/html, text/plain, application/json;q=0.9, */*;q=0.1",
|
|
42
97
|
"User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
|
|
43
98
|
},
|
|
44
99
|
},
|
|
45
|
-
|
|
100
|
+
(response) => {
|
|
101
|
+
if (controller.signal.aborted) {
|
|
102
|
+
// The attempt deadline or caller cancellation fired before response
|
|
103
|
+
// headers: drop the socket and report the reason.
|
|
104
|
+
response.destroy();
|
|
105
|
+
finish(() => reject(attemptExpired ? unreachableError() : abortedError()));
|
|
106
|
+
} else finish(() => resolve(response));
|
|
107
|
+
},
|
|
46
108
|
);
|
|
47
|
-
outgoing.once("error",
|
|
109
|
+
outgoing.once("error", (error) => {
|
|
110
|
+
// The per-attempt deadline surfaces as a raw AbortError from the HTTP
|
|
111
|
+
// client; report it as an unreachable address instead so the next
|
|
112
|
+
// validated address is tried and the final error explains itself.
|
|
113
|
+
if (attemptExpired) finish(() => reject(unreachableError()));
|
|
114
|
+
else finish(() => reject(error));
|
|
115
|
+
});
|
|
48
116
|
outgoing.end();
|
|
49
117
|
});
|
|
50
118
|
}
|
|
51
119
|
|
|
120
|
+
export interface RequestPinnedOptions {
|
|
121
|
+
/** Per-address connect deadline used to fall back to the next address. */
|
|
122
|
+
attemptTimeoutMs?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sends a request to the target using its validated addresses in sequence.
|
|
127
|
+
*
|
|
128
|
+
* @param options - Optional settings for individual connection attempts.
|
|
129
|
+
* @param options.attemptTimeoutMs - Maximum time allowed for each connection attempt in milliseconds.
|
|
130
|
+
* @returns The first successful HTTP response.
|
|
131
|
+
* @throws The final connection error if all addresses fail, or the abort error if the signal is aborted.
|
|
132
|
+
*/
|
|
133
|
+
export async function requestPinned(
|
|
134
|
+
target: ValidatedTarget,
|
|
135
|
+
signal: AbortSignal,
|
|
136
|
+
options: RequestPinnedOptions = {},
|
|
137
|
+
): Promise<IncomingMessage> {
|
|
138
|
+
const attemptTimeoutMs = options.attemptTimeoutMs ?? CONNECT_ATTEMPT_TIMEOUT_MS;
|
|
139
|
+
const addresses = target.addresses?.length ? target.addresses : [target.address];
|
|
140
|
+
let lastError: unknown;
|
|
141
|
+
for (const address of addresses) {
|
|
142
|
+
try {
|
|
143
|
+
return await requestOnce(target, address, signal, attemptTimeoutMs);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
lastError = error;
|
|
146
|
+
if (signal.aborted) throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw lastError ?? new Error("web_fetch could not connect.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Retrieves a response header value by name.
|
|
154
|
+
*
|
|
155
|
+
* @param response - The response containing the header
|
|
156
|
+
* @param name - The header name to retrieve
|
|
157
|
+
* @returns The first header value, or `undefined` when the header is absent
|
|
158
|
+
*/
|
|
52
159
|
export function responseHeader(response: IncomingMessage, name: string): string | undefined {
|
|
53
160
|
const value = response.headers[name];
|
|
54
161
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -57,23 +164,41 @@ export function responseHeader(response: IncomingMessage, name: string): string
|
|
|
57
164
|
export async function readResponseBytes(
|
|
58
165
|
response: IncomingMessage,
|
|
59
166
|
maxBytes: number,
|
|
167
|
+
signal?: AbortSignal,
|
|
60
168
|
): Promise<Uint8Array> {
|
|
61
169
|
const declared = Number(responseHeader(response, "content-length"));
|
|
62
170
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
63
171
|
response.destroy();
|
|
64
172
|
throw new Error(responseTooLargeMessage(declared, maxBytes, true));
|
|
65
173
|
}
|
|
174
|
+
// Once response headers arrive the connect deadline and caller signal are no
|
|
175
|
+
// longer wired to the socket, so a stalled body would otherwise hang the
|
|
176
|
+
// fetch forever. Keep the caller signal attached for the whole body read and
|
|
177
|
+
// drop the socket when it fires.
|
|
178
|
+
const forwardAbort = () => response.destroy();
|
|
179
|
+
if (signal?.aborted) response.destroy();
|
|
180
|
+
else signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
66
181
|
const chunks: Uint8Array[] = [];
|
|
67
182
|
let total = 0;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
183
|
+
try {
|
|
184
|
+
for await (const value of response) {
|
|
185
|
+
const chunk = typeof value === "string" ? encoder.encode(value) : new Uint8Array(value);
|
|
186
|
+
total += chunk.byteLength;
|
|
187
|
+
if (total > maxBytes) {
|
|
188
|
+
response.destroy();
|
|
189
|
+
throw new Error(responseTooLargeMessage(total, maxBytes, false));
|
|
190
|
+
}
|
|
191
|
+
chunks.push(chunk);
|
|
74
192
|
}
|
|
75
|
-
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (signal?.aborted) throw abortedError();
|
|
195
|
+
throw error;
|
|
196
|
+
} finally {
|
|
197
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
76
198
|
}
|
|
199
|
+
// Destroying the socket can end the stream without an error; detect a
|
|
200
|
+
// mid-read abort here as well so truncated bodies never look complete.
|
|
201
|
+
if (signal?.aborted) throw abortedError();
|
|
77
202
|
const output = new Uint8Array(total);
|
|
78
203
|
let offset = 0;
|
|
79
204
|
for (const chunk of chunks) {
|