@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.5.3",
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",
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(await readResponseBytes(response, FETCH_MAX_BYTES), contentTypeHeader);
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";
@@ -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
- const timer = setTimeout(() => controller.abort(), attemptTimeoutMs);
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 headers: drop the socket
90
- // and treat the address as unreachable so the next one is tried.
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) => finish(() => reject(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
- for await (const value of response) {
161
- const chunk = typeof value === "string" ? encoder.encode(value) : new Uint8Array(value);
162
- total += chunk.byteLength;
163
- if (total > maxBytes) {
164
- response.destroy();
165
- throw new Error(responseTooLargeMessage(total, maxBytes, false));
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
- chunks.push(chunk);
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) {