@zeldrisho/pi-web-fetch 0.5.3 → 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 +7 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/fetch.ts +4 -1
- package/src/network-transport.ts +49 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
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)
|
|
4
11
|
|
|
5
12
|
### 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
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-transport.ts
CHANGED
|
@@ -15,6 +15,13 @@ export const CONNECT_ATTEMPT_TIMEOUT_MS = 4_000;
|
|
|
15
15
|
|
|
16
16
|
const encoder = new TextEncoder();
|
|
17
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
|
+
|
|
18
25
|
/**
|
|
19
26
|
* Formats an error message for a response that exceeds the raw download limit.
|
|
20
27
|
*
|
|
@@ -62,7 +69,11 @@ async function requestOnce(
|
|
|
62
69
|
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
63
70
|
return await new Promise((resolve, reject) => {
|
|
64
71
|
const controller = new AbortController();
|
|
65
|
-
|
|
72
|
+
let attemptExpired = false;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
attemptExpired = true;
|
|
75
|
+
controller.abort();
|
|
76
|
+
}, attemptTimeoutMs);
|
|
66
77
|
const forwardAbort = () => controller.abort();
|
|
67
78
|
if (signal.aborted) controller.abort();
|
|
68
79
|
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
@@ -74,6 +85,8 @@ async function requestOnce(
|
|
|
74
85
|
signal.removeEventListener("abort", forwardAbort);
|
|
75
86
|
callback();
|
|
76
87
|
};
|
|
88
|
+
const unreachableError = () =>
|
|
89
|
+
new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`);
|
|
77
90
|
const outgoing = request(
|
|
78
91
|
target.url,
|
|
79
92
|
{
|
|
@@ -86,18 +99,20 @@ async function requestOnce(
|
|
|
86
99
|
},
|
|
87
100
|
(response) => {
|
|
88
101
|
if (controller.signal.aborted) {
|
|
89
|
-
// The attempt deadline fired before response
|
|
90
|
-
//
|
|
102
|
+
// The attempt deadline or caller cancellation fired before response
|
|
103
|
+
// headers: drop the socket and report the reason.
|
|
91
104
|
response.destroy();
|
|
92
|
-
finish(() =>
|
|
93
|
-
reject(
|
|
94
|
-
new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`),
|
|
95
|
-
),
|
|
96
|
-
);
|
|
105
|
+
finish(() => reject(attemptExpired ? unreachableError() : abortedError()));
|
|
97
106
|
} else finish(() => resolve(response));
|
|
98
107
|
},
|
|
99
108
|
);
|
|
100
|
-
outgoing.once("error", (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
|
+
});
|
|
101
116
|
outgoing.end();
|
|
102
117
|
});
|
|
103
118
|
}
|
|
@@ -149,23 +164,41 @@ export function responseHeader(response: IncomingMessage, name: string): string
|
|
|
149
164
|
export async function readResponseBytes(
|
|
150
165
|
response: IncomingMessage,
|
|
151
166
|
maxBytes: number,
|
|
167
|
+
signal?: AbortSignal,
|
|
152
168
|
): Promise<Uint8Array> {
|
|
153
169
|
const declared = Number(responseHeader(response, "content-length"));
|
|
154
170
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
155
171
|
response.destroy();
|
|
156
172
|
throw new Error(responseTooLargeMessage(declared, maxBytes, true));
|
|
157
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 });
|
|
158
181
|
const chunks: Uint8Array[] = [];
|
|
159
182
|
let total = 0;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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);
|
|
166
192
|
}
|
|
167
|
-
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (signal?.aborted) throw abortedError();
|
|
195
|
+
throw error;
|
|
196
|
+
} finally {
|
|
197
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
168
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();
|
|
169
202
|
const output = new Uint8Array(total);
|
|
170
203
|
let offset = 0;
|
|
171
204
|
for (const chunk of chunks) {
|