@zeldrisho/pi-web-fetch 0.5.1 → 0.5.3
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/package.json +6 -5
- package/src/extract.ts +22 -2
- package/src/network-policy.ts +32 -2
- package/src/network-transport.ts +99 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
|
+
|
|
3
|
+
## [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
|
+
|
|
5
|
+
### Bug fixes
|
|
6
|
+
|
|
7
|
+
- **web-fetch:** Fall back across validated addresses before timing out ([32a000b](https://github.com/zeldrisho/pi-packages/commit/32a000bf367268d4ff38e7b45cb73d45ea894f5d))
|
|
8
|
+
- **web-fetch:** Cancel attempts when the caller signal is already aborted ([2f62c95](https://github.com/zeldrisho/pi-packages/commit/2f62c95906e171cc974c9f5cdbd66cf3ecd65b56))
|
|
9
|
+
|
|
10
|
+
### Maintenance
|
|
11
|
+
|
|
12
|
+
- **deps:** Upgrade Vite+ toolchain to 0.2.9 ([9921cf3](https://github.com/zeldrisho/pi-packages/commit/9921cf3ffbed29f9c08ca3ab595a5096fadf2be0))
|
|
13
|
+
|
|
14
|
+
## [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)
|
|
15
|
+
|
|
16
|
+
### Bug fixes
|
|
17
|
+
|
|
18
|
+
- **web-fetch:** Discard malformed schema metadata ([2ac7b02](https://github.com/zeldrisho/pi-packages/commit/2ac7b02f8952950aa60d4d22f080ab0774cdf50a))
|
|
19
|
+
|
|
2
20
|
## [0.5.1](https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.0...pi-web-fetch-v0.5.1) (2026-08-03)
|
|
3
21
|
|
|
4
22
|
|
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.3",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -35,11 +35,12 @@
|
|
|
35
35
|
"linkedom": "^0.18.13"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
39
|
-
"@earendil-works/pi-tui": "^0.
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
39
|
+
"@earendil-works/pi-tui": "^0.84.0",
|
|
40
40
|
"typebox": "^1.1.24",
|
|
41
|
-
"typescript": "^
|
|
42
|
-
"vite
|
|
41
|
+
"typescript": "^7.0.0",
|
|
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/extract.ts
CHANGED
|
@@ -2,6 +2,24 @@ import { parseHTML } from "linkedom";
|
|
|
2
2
|
|
|
3
3
|
const RAW_ID_SELECTOR_SAFE = /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/;
|
|
4
4
|
|
|
5
|
+
/** Removes schema.org scripts that Defuddle would report directly to the process console. */
|
|
6
|
+
function removeMalformedSchemaOrgData(document: Document): void {
|
|
7
|
+
for (const script of document.querySelectorAll<HTMLScriptElement>(
|
|
8
|
+
'script[type="application/ld+json"]',
|
|
9
|
+
)) {
|
|
10
|
+
const jsonContent = (script.textContent || "")
|
|
11
|
+
.replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "")
|
|
12
|
+
.replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, "$1")
|
|
13
|
+
.replace(/^\s*(\*\/|\/\*)\s*|\s*(\*\/|\/\*)\s*$/g, "")
|
|
14
|
+
.trim();
|
|
15
|
+
try {
|
|
16
|
+
if (JSON.parse(jsonContent) === null) script.remove();
|
|
17
|
+
} catch {
|
|
18
|
+
script.remove();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
/**
|
|
6
24
|
* Replaces element IDs that are unsafe for CSS selectors and updates matching fragment links.
|
|
7
25
|
*
|
|
@@ -71,8 +89,10 @@ export async function extractHtmlToMarkdown(
|
|
|
71
89
|
try {
|
|
72
90
|
const { Defuddle } = await import("defuddle/node");
|
|
73
91
|
const { document } = parseHTML(html);
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
const defuddleDocument = document as unknown as Document;
|
|
93
|
+
removeMalformedSchemaOrgData(defuddleDocument);
|
|
94
|
+
normalizeSelectorUnsafeIds(defuddleDocument);
|
|
95
|
+
const result = await Defuddle(defuddleDocument, baseUrl.toString(), {
|
|
76
96
|
markdown: true,
|
|
77
97
|
useAsync: false,
|
|
78
98
|
});
|
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,28 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Formats an error message for a response that exceeds the raw download limit.
|
|
20
|
+
*
|
|
21
|
+
* @param receivedBytes - The number of bytes received or reported by the response.
|
|
22
|
+
* @param maxBytes - The maximum allowed number of bytes.
|
|
23
|
+
* @param sizeIsExact - Whether `receivedBytes` is the exact response size.
|
|
24
|
+
* @returns A message describing the response size and configured limit.
|
|
25
|
+
*/
|
|
11
26
|
function responseTooLargeMessage(
|
|
12
27
|
receivedBytes: number,
|
|
13
28
|
maxBytes: number,
|
|
@@ -22,33 +37,110 @@ function responseTooLargeMessage(
|
|
|
22
37
|
].join(" ");
|
|
23
38
|
}
|
|
24
39
|
|
|
25
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Sends a request to a specific validated network address.
|
|
42
|
+
*
|
|
43
|
+
* @param target - The validated request target.
|
|
44
|
+
* @param address - The IPv4 or IPv6 address to use for the connection.
|
|
45
|
+
* @param signal - Signal used to cancel the request.
|
|
46
|
+
* @param attemptTimeoutMs - Maximum time allowed for the connection attempt.
|
|
47
|
+
* @returns The received response.
|
|
48
|
+
* @throws If `address` is not a valid IPv4 or IPv6 address.
|
|
49
|
+
*/
|
|
50
|
+
async function requestOnce(
|
|
26
51
|
target: ValidatedTarget,
|
|
52
|
+
address: string,
|
|
27
53
|
signal: AbortSignal,
|
|
54
|
+
attemptTimeoutMs: number,
|
|
28
55
|
): Promise<IncomingMessage> {
|
|
56
|
+
const family = isIP(address);
|
|
57
|
+
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
|
|
29
58
|
const lookup: LookupFunction = (_hostname, options, callback) => {
|
|
30
|
-
if (options.all) callback(null, [{ address
|
|
31
|
-
else callback(null,
|
|
59
|
+
if (options.all) callback(null, [{ address, family }]);
|
|
60
|
+
else callback(null, address, family);
|
|
32
61
|
};
|
|
33
62
|
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
34
63
|
return await new Promise((resolve, reject) => {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timer = setTimeout(() => controller.abort(), attemptTimeoutMs);
|
|
66
|
+
const forwardAbort = () => controller.abort();
|
|
67
|
+
if (signal.aborted) controller.abort();
|
|
68
|
+
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
69
|
+
let settled = false;
|
|
70
|
+
const finish = (callback: () => void) => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
signal.removeEventListener("abort", forwardAbort);
|
|
75
|
+
callback();
|
|
76
|
+
};
|
|
35
77
|
const outgoing = request(
|
|
36
78
|
target.url,
|
|
37
79
|
{
|
|
38
80
|
lookup,
|
|
39
|
-
signal,
|
|
81
|
+
signal: controller.signal,
|
|
40
82
|
headers: {
|
|
41
83
|
Accept: "text/markdown, text/html, text/plain, application/json;q=0.9, */*;q=0.1",
|
|
42
84
|
"User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
|
|
43
85
|
},
|
|
44
86
|
},
|
|
45
|
-
|
|
87
|
+
(response) => {
|
|
88
|
+
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.
|
|
91
|
+
response.destroy();
|
|
92
|
+
finish(() =>
|
|
93
|
+
reject(
|
|
94
|
+
new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
} else finish(() => resolve(response));
|
|
98
|
+
},
|
|
46
99
|
);
|
|
47
|
-
outgoing.once("error", reject);
|
|
100
|
+
outgoing.once("error", (error) => finish(() => reject(error)));
|
|
48
101
|
outgoing.end();
|
|
49
102
|
});
|
|
50
103
|
}
|
|
51
104
|
|
|
105
|
+
export interface RequestPinnedOptions {
|
|
106
|
+
/** Per-address connect deadline used to fall back to the next address. */
|
|
107
|
+
attemptTimeoutMs?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Sends a request to the target using its validated addresses in sequence.
|
|
112
|
+
*
|
|
113
|
+
* @param options - Optional settings for individual connection attempts.
|
|
114
|
+
* @param options.attemptTimeoutMs - Maximum time allowed for each connection attempt in milliseconds.
|
|
115
|
+
* @returns The first successful HTTP response.
|
|
116
|
+
* @throws The final connection error if all addresses fail, or the abort error if the signal is aborted.
|
|
117
|
+
*/
|
|
118
|
+
export async function requestPinned(
|
|
119
|
+
target: ValidatedTarget,
|
|
120
|
+
signal: AbortSignal,
|
|
121
|
+
options: RequestPinnedOptions = {},
|
|
122
|
+
): Promise<IncomingMessage> {
|
|
123
|
+
const attemptTimeoutMs = options.attemptTimeoutMs ?? CONNECT_ATTEMPT_TIMEOUT_MS;
|
|
124
|
+
const addresses = target.addresses?.length ? target.addresses : [target.address];
|
|
125
|
+
let lastError: unknown;
|
|
126
|
+
for (const address of addresses) {
|
|
127
|
+
try {
|
|
128
|
+
return await requestOnce(target, address, signal, attemptTimeoutMs);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
lastError = error;
|
|
131
|
+
if (signal.aborted) throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw lastError ?? new Error("web_fetch could not connect.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Retrieves a response header value by name.
|
|
139
|
+
*
|
|
140
|
+
* @param response - The response containing the header
|
|
141
|
+
* @param name - The header name to retrieve
|
|
142
|
+
* @returns The first header value, or `undefined` when the header is absent
|
|
143
|
+
*/
|
|
52
144
|
export function responseHeader(response: IncomingMessage, name: string): string | undefined {
|
|
53
145
|
const value = response.headers[name];
|
|
54
146
|
return Array.isArray(value) ? value[0] : value;
|