@zeldrisho/pi-web-fetch 0.8.1 → 0.9.0
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 +16 -1
- package/README.md +13 -50
- package/package.json +4 -6
- package/src/abort.ts +3 -0
- package/src/cache.ts +155 -8
- package/src/content.ts +16 -0
- package/src/evidence.ts +10 -0
- package/src/extract.ts +183 -1
- package/src/fetch.ts +54 -0
- package/src/focus.ts +17 -0
- package/src/index.ts +10 -1
- package/src/inflight.ts +10 -0
- package/src/network-policy.ts +17 -0
- package/src/network-redirects.ts +35 -0
- package/src/network-transport.ts +27 -0
- package/src/network.ts +1 -0
- package/src/outline.ts +38 -0
- package/src/redact.ts +7 -0
- package/src/render.ts +3 -0
- package/src/service.ts +252 -35
package/src/focus.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const WORD_PATTERN = /[\p{L}\p{N}_+#]+/gu;
|
|
2
|
+
|
|
2
3
|
const HEADING_PATTERN = /^#{1,6}\s+\S/;
|
|
3
4
|
|
|
4
5
|
/** Metadata describing deterministic query-focused extraction. */
|
|
@@ -35,24 +36,31 @@ function splitSections(markdown: string): Section[] {
|
|
|
35
36
|
.split(/\n{2,}/)
|
|
36
37
|
.map((block) => block.trim())
|
|
37
38
|
.filter(Boolean);
|
|
39
|
+
|
|
38
40
|
if (blocks.length === 0) return [];
|
|
39
41
|
|
|
40
42
|
const lines = markdown.split(/\r?\n/);
|
|
41
43
|
const hasHeadings = lines.some((line) => HEADING_PATTERN.test(line));
|
|
44
|
+
|
|
42
45
|
if (!hasHeadings) return blocks.map((block) => ({ markdown: block, tokens: tokens(block) }));
|
|
43
46
|
|
|
44
47
|
const sections: string[] = [];
|
|
45
48
|
let current: string[] = [];
|
|
49
|
+
|
|
46
50
|
const flush = () => {
|
|
47
51
|
const section = current.join("\n").trim();
|
|
52
|
+
|
|
48
53
|
if (section) sections.push(section);
|
|
49
54
|
current = [];
|
|
50
55
|
};
|
|
56
|
+
|
|
51
57
|
for (const line of lines) {
|
|
52
58
|
if (HEADING_PATTERN.test(line)) flush();
|
|
53
59
|
current.push(line);
|
|
54
60
|
}
|
|
61
|
+
|
|
55
62
|
flush();
|
|
63
|
+
|
|
56
64
|
return sections.map((section) => ({ markdown: section, tokens: tokens(section) }));
|
|
57
65
|
}
|
|
58
66
|
|
|
@@ -83,7 +91,9 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
83
91
|
|
|
84
92
|
const averageLength =
|
|
85
93
|
sections.reduce((sum, section) => sum + section.tokens.length, 0) / sections.length || 1;
|
|
94
|
+
|
|
86
95
|
const documentFrequency = new Map<string, number>();
|
|
96
|
+
|
|
87
97
|
for (const term of queryTerms) {
|
|
88
98
|
documentFrequency.set(
|
|
89
99
|
term,
|
|
@@ -93,24 +103,31 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
93
103
|
|
|
94
104
|
const selected = sections.filter((section) => {
|
|
95
105
|
const frequencies = new Map<string, number>();
|
|
106
|
+
|
|
96
107
|
for (const token of section.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
|
|
97
108
|
const lengthNormalization = 1 - 0.75 + 0.75 * (section.tokens.length / averageLength);
|
|
98
109
|
let score = 0;
|
|
110
|
+
|
|
99
111
|
for (const term of queryTerms) {
|
|
100
112
|
const frequency = frequencies.get(term) ?? 0;
|
|
113
|
+
|
|
101
114
|
if (frequency === 0) continue;
|
|
102
115
|
const frequencyInDocuments = documentFrequency.get(term) ?? 0;
|
|
116
|
+
|
|
103
117
|
const inverseDocumentFrequency = Math.log(
|
|
104
118
|
1 + (sections.length - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5),
|
|
105
119
|
);
|
|
120
|
+
|
|
106
121
|
score +=
|
|
107
122
|
inverseDocumentFrequency *
|
|
108
123
|
((frequency * (1.2 + 1)) / (frequency + 1.2 * lengthNormalization));
|
|
109
124
|
}
|
|
125
|
+
|
|
110
126
|
return score > 0;
|
|
111
127
|
});
|
|
112
128
|
|
|
113
129
|
const focused = selected.map((section) => section.markdown).join("\n\n");
|
|
130
|
+
|
|
114
131
|
return {
|
|
115
132
|
markdown: focused,
|
|
116
133
|
details: {
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,9 @@ import { formatCollapsibleOutput } from "./render";
|
|
|
15
15
|
import { executeWebFetch } from "./service";
|
|
16
16
|
|
|
17
17
|
export { ExpiringLruCache } from "./cache";
|
|
18
|
+
|
|
18
19
|
export type { FetchResult } from "./content";
|
|
20
|
+
|
|
19
21
|
export {
|
|
20
22
|
diagnoseExtraction,
|
|
21
23
|
extractDocumentLinks,
|
|
@@ -23,10 +25,15 @@ export {
|
|
|
23
25
|
type ExtractedLinks,
|
|
24
26
|
type ExtractionDiagnostics,
|
|
25
27
|
} from "./evidence";
|
|
28
|
+
|
|
26
29
|
export { fetchRemoteContent, type FetchRemoteDependencies } from "./fetch";
|
|
30
|
+
|
|
27
31
|
export { focusMarkdown, type FocusDetails, type FocusResult } from "./focus";
|
|
32
|
+
|
|
28
33
|
export { createDocumentOutline, type DocumentOutline, type OutlineHeading } from "./outline";
|
|
34
|
+
|
|
29
35
|
export { redactUrlForDisplay } from "./redact";
|
|
36
|
+
|
|
30
37
|
export {
|
|
31
38
|
executeWebFetch,
|
|
32
39
|
type CacheStatus,
|
|
@@ -34,6 +41,7 @@ export {
|
|
|
34
41
|
type WebFetchParameters,
|
|
35
42
|
type WebFetchTruncationDetails,
|
|
36
43
|
} from "./service";
|
|
44
|
+
|
|
37
45
|
export {
|
|
38
46
|
FETCH_MAX_BYTES,
|
|
39
47
|
isPrivateAddress,
|
|
@@ -85,7 +93,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
85
93
|
name: "web_fetch",
|
|
86
94
|
label: "Web Fetch",
|
|
87
95
|
description:
|
|
88
|
-
"Fetch a public HTTP(S) page and return a bounded Markdown content chunk with continuation metadata.",
|
|
96
|
+
"Fetch a public HTTP(S) page and return a bounded Markdown content chunk with continuation metadata. URL fragments start at the matching heading or anchor; explicit offsets take precedence.",
|
|
89
97
|
promptSnippet: "Read a public web page as bounded Markdown",
|
|
90
98
|
promptGuidelines: [
|
|
91
99
|
"Use web_fetch for a user-provided URL or to inspect relevant sources found with web_search.",
|
|
@@ -110,6 +118,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
110
118
|
if (isPartial) return new Text(theme.fg("warning", "Fetching…"), 0, 0);
|
|
111
119
|
|
|
112
120
|
const content = result.content.find((item) => item.type === "text");
|
|
121
|
+
|
|
113
122
|
return new Text(
|
|
114
123
|
content?.type === "text"
|
|
115
124
|
? formatCollapsibleOutput(content.text, expanded, theme)
|
package/src/inflight.ts
CHANGED
|
@@ -19,12 +19,16 @@ function waitForCaller<T>(
|
|
|
19
19
|
cancelledMessage: string,
|
|
20
20
|
): Promise<T> {
|
|
21
21
|
if (!signal) return operation;
|
|
22
|
+
|
|
22
23
|
return new Promise((resolve, reject) => {
|
|
23
24
|
const abort = (): void => reject(new Error(cancelledMessage));
|
|
25
|
+
|
|
24
26
|
if (signal.aborted) {
|
|
25
27
|
abort();
|
|
28
|
+
|
|
26
29
|
return;
|
|
27
30
|
}
|
|
31
|
+
|
|
28
32
|
signal.addEventListener("abort", abort, { once: true });
|
|
29
33
|
operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
30
34
|
});
|
|
@@ -60,7 +64,10 @@ export class InflightCoalescer<K, V> {
|
|
|
60
64
|
signal: AbortSignal | undefined,
|
|
61
65
|
cancelledMessage: string,
|
|
62
66
|
): Promise<V> {
|
|
67
|
+
if (signal?.aborted) throw new Error(cancelledMessage);
|
|
68
|
+
|
|
63
69
|
let entry = this.#entries.get(key);
|
|
70
|
+
|
|
64
71
|
if (!entry) {
|
|
65
72
|
if (this.#entries.size >= this.maxEntries) return operation(signal);
|
|
66
73
|
const controller = new AbortController();
|
|
@@ -79,16 +86,19 @@ export class InflightCoalescer<K, V> {
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
entry.waiters += 1;
|
|
89
|
+
|
|
82
90
|
try {
|
|
83
91
|
return await waitForCaller(entry.promise, signal, cancelledMessage);
|
|
84
92
|
} finally {
|
|
85
93
|
entry.waiters -= 1;
|
|
94
|
+
|
|
86
95
|
if (entry.waiters === 0 && !entry.settled) entry.controller.abort();
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
#settle(key: K, entry: InflightEntry<V>): void {
|
|
91
100
|
entry.settled = true;
|
|
101
|
+
|
|
92
102
|
if (this.#entries.get(key) === entry) this.#entries.delete(key);
|
|
93
103
|
}
|
|
94
104
|
}
|
package/src/network-policy.ts
CHANGED
|
@@ -64,15 +64,19 @@ const GLOBALLY_REACHABLE_IPV6_EXCEPTIONS = [
|
|
|
64
64
|
] as const;
|
|
65
65
|
|
|
66
66
|
const blockedIPv4Addresses = new BlockList();
|
|
67
|
+
|
|
67
68
|
const blockedIPv6Addresses = new BlockList();
|
|
69
|
+
|
|
68
70
|
const allowedIPv6Addresses = new BlockList();
|
|
69
71
|
|
|
70
72
|
for (const [network, prefix] of BLOCKED_IPV4_RANGES) {
|
|
71
73
|
blockedIPv4Addresses.addSubnet(network, prefix, "ipv4");
|
|
72
74
|
}
|
|
75
|
+
|
|
73
76
|
for (const [network, prefix] of BLOCKED_IPV6_RANGES) {
|
|
74
77
|
blockedIPv6Addresses.addSubnet(network, prefix, "ipv6");
|
|
75
78
|
}
|
|
79
|
+
|
|
76
80
|
for (const [network, prefix] of GLOBALLY_REACHABLE_IPV6_EXCEPTIONS) {
|
|
77
81
|
allowedIPv6Addresses.addSubnet(network, prefix, "ipv6");
|
|
78
82
|
}
|
|
@@ -106,11 +110,15 @@ export type ResolveAddresses = (hostname: string) => Promise<string[]>;
|
|
|
106
110
|
*/
|
|
107
111
|
export function isPrivateAddress(address: string): boolean {
|
|
108
112
|
const family = isIP(address);
|
|
113
|
+
|
|
109
114
|
if (family === 4) return blockedIPv4Addresses.check(address, "ipv4");
|
|
115
|
+
|
|
110
116
|
if (family === 6) {
|
|
111
117
|
if (allowedIPv6Addresses.check(address, "ipv6")) return false;
|
|
118
|
+
|
|
112
119
|
return blockedIPv6Addresses.check(address, "ipv6");
|
|
113
120
|
}
|
|
121
|
+
|
|
114
122
|
return true;
|
|
115
123
|
}
|
|
116
124
|
|
|
@@ -136,8 +144,10 @@ export async function validateRemoteUrl(
|
|
|
136
144
|
resolveHostname?: ResolveAddresses,
|
|
137
145
|
): Promise<ValidatedTarget> {
|
|
138
146
|
const url = value instanceof URL ? value : new URL(value);
|
|
147
|
+
|
|
139
148
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
140
149
|
throw new Error("web_fetch only supports HTTP and HTTPS URLs.");
|
|
150
|
+
|
|
141
151
|
if (url.username || url.password)
|
|
142
152
|
throw new Error("web_fetch blocks URLs containing credentials.");
|
|
143
153
|
|
|
@@ -145,24 +155,31 @@ export async function validateRemoteUrl(
|
|
|
145
155
|
.toLowerCase()
|
|
146
156
|
.replace(/^\[|\]$/g, "")
|
|
147
157
|
.replace(/\.$/, "");
|
|
158
|
+
|
|
148
159
|
if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
149
160
|
throw new Error("web_fetch blocks local hostnames.");
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
let addresses: string[];
|
|
164
|
+
|
|
153
165
|
if (isIP(hostname)) addresses = [hostname];
|
|
154
166
|
else if (resolveHostname) addresses = await resolveHostname(hostname);
|
|
155
167
|
else {
|
|
156
168
|
const records = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
157
169
|
addresses = [];
|
|
170
|
+
|
|
158
171
|
for (const record of records) addresses.push(record.address);
|
|
159
172
|
}
|
|
173
|
+
|
|
160
174
|
if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
|
|
161
175
|
throw new Error(`web_fetch blocks private or reserved network targets (${hostname}).`);
|
|
162
176
|
}
|
|
177
|
+
|
|
163
178
|
const ordered = preferIpv4First(addresses);
|
|
164
179
|
const address = ordered[0];
|
|
165
180
|
const family = isIP(address);
|
|
181
|
+
|
|
166
182
|
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${hostname}.`);
|
|
183
|
+
|
|
167
184
|
return { url, address, family, addresses: ordered };
|
|
168
185
|
}
|
package/src/network-redirects.ts
CHANGED
|
@@ -5,15 +5,20 @@ import { requestPinned, responseHeader } from "./network-transport";
|
|
|
5
5
|
|
|
6
6
|
/** Maximum number of HTTP redirects to follow before aborting. */
|
|
7
7
|
export const FETCH_MAX_REDIRECTS = 5;
|
|
8
|
+
|
|
8
9
|
const MAX_ORIGIN_CONCURRENCY = 4;
|
|
10
|
+
|
|
9
11
|
const MAX_RETRIES = 2;
|
|
12
|
+
|
|
10
13
|
const MAX_RETRY_DELAY_MS = 10_000;
|
|
14
|
+
|
|
11
15
|
const BASE_RETRY_DELAY_MS = 250;
|
|
12
16
|
|
|
13
17
|
interface OriginState {
|
|
14
18
|
active: number;
|
|
15
19
|
waiting: Array<() => void>;
|
|
16
20
|
}
|
|
21
|
+
|
|
17
22
|
const origins = new Map<string, OriginState>();
|
|
18
23
|
|
|
19
24
|
/** Optional dependencies for redirect handling (used for testing). */
|
|
@@ -28,21 +33,27 @@ export interface RedirectDependencies {
|
|
|
28
33
|
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** Creates the consistent abort error used by redirect and retry coordination. */
|
|
31
37
|
function abortError(): Error {
|
|
32
38
|
const error = new Error("Operation aborted.");
|
|
33
39
|
error.name = "AbortError";
|
|
40
|
+
|
|
34
41
|
return error;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/** Reserves an origin-concurrency slot and returns a function that releases it. */
|
|
37
45
|
async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() => void> {
|
|
38
46
|
const state = origins.get(origin) ?? { active: 0, waiting: [] };
|
|
39
47
|
origins.set(origin, state);
|
|
40
48
|
let reserved = false;
|
|
49
|
+
|
|
41
50
|
const release = () => {
|
|
42
51
|
state.active -= 1;
|
|
43
52
|
state.waiting.shift()?.();
|
|
53
|
+
|
|
44
54
|
if (state.active === 0 && state.waiting.length === 0) origins.delete(origin);
|
|
45
55
|
};
|
|
56
|
+
|
|
46
57
|
if (state.active >= MAX_ORIGIN_CONCURRENCY) {
|
|
47
58
|
await new Promise<void>((resolve, reject) => {
|
|
48
59
|
const enter = () => {
|
|
@@ -51,11 +62,14 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
|
|
|
51
62
|
reserved = true;
|
|
52
63
|
resolve();
|
|
53
64
|
};
|
|
65
|
+
|
|
54
66
|
const cancel = () => {
|
|
55
67
|
const index = state.waiting.indexOf(enter);
|
|
68
|
+
|
|
56
69
|
if (index >= 0) state.waiting.splice(index, 1);
|
|
57
70
|
reject(abortError());
|
|
58
71
|
};
|
|
72
|
+
|
|
59
73
|
if (signal.aborted) cancel();
|
|
60
74
|
else {
|
|
61
75
|
state.waiting.push(enter);
|
|
@@ -63,40 +77,52 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
|
|
|
63
77
|
}
|
|
64
78
|
});
|
|
65
79
|
}
|
|
80
|
+
|
|
66
81
|
if (signal.aborted) {
|
|
67
82
|
if (reserved) release();
|
|
68
83
|
throw abortError();
|
|
69
84
|
}
|
|
85
|
+
|
|
70
86
|
if (!reserved) state.active += 1;
|
|
87
|
+
|
|
71
88
|
return release;
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
/** Waits for a retry delay while allowing the caller to cancel immediately. */
|
|
74
92
|
async function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
75
93
|
await new Promise<void>((resolve, reject) => {
|
|
76
94
|
const timer = setTimeout(done, milliseconds);
|
|
95
|
+
|
|
77
96
|
function done(): void {
|
|
78
97
|
signal.removeEventListener("abort", cancel);
|
|
79
98
|
resolve();
|
|
80
99
|
}
|
|
100
|
+
|
|
81
101
|
function cancel(): void {
|
|
82
102
|
clearTimeout(timer);
|
|
83
103
|
reject(abortError());
|
|
84
104
|
}
|
|
105
|
+
|
|
85
106
|
if (signal.aborted) cancel();
|
|
86
107
|
else signal.addEventListener("abort", cancel, { once: true });
|
|
87
108
|
});
|
|
88
109
|
}
|
|
89
110
|
|
|
111
|
+
/** Parses a Retry-After header into a bounded delay in milliseconds. */
|
|
90
112
|
function retryAfterMilliseconds(value: string | undefined, now = Date.now()): number | undefined {
|
|
91
113
|
if (!value) return undefined;
|
|
92
114
|
const seconds = Number(value.trim());
|
|
115
|
+
|
|
93
116
|
if (Number.isFinite(seconds) && seconds >= 0)
|
|
94
117
|
return Math.min(seconds * 1_000, MAX_RETRY_DELAY_MS);
|
|
95
118
|
const date = Date.parse(value);
|
|
119
|
+
|
|
96
120
|
if (!Number.isFinite(date)) return undefined;
|
|
121
|
+
|
|
97
122
|
return Math.min(Math.max(0, date - now), MAX_RETRY_DELAY_MS);
|
|
98
123
|
}
|
|
99
124
|
|
|
125
|
+
/** Sends one origin-limited request and retries transient rate-limit responses. */
|
|
100
126
|
async function coordinatedRequest(
|
|
101
127
|
target: ValidatedTarget,
|
|
102
128
|
signal: AbortSignal,
|
|
@@ -107,17 +133,22 @@ async function coordinatedRequest(
|
|
|
107
133
|
dependencies.request ??
|
|
108
134
|
((value, requestSignal, requestHeaders) =>
|
|
109
135
|
requestPinned(value, requestSignal, { headers: requestHeaders }));
|
|
136
|
+
|
|
110
137
|
const random = dependencies.random ?? Math.random;
|
|
111
138
|
const sleep = dependencies.sleep ?? defaultSleep;
|
|
139
|
+
|
|
112
140
|
for (let attempt = 0; ; attempt += 1) {
|
|
113
141
|
const release = await acquireOrigin(target.url.origin, signal);
|
|
114
142
|
let response: IncomingMessage;
|
|
143
|
+
|
|
115
144
|
try {
|
|
116
145
|
response = await request(target, signal, headers);
|
|
117
146
|
} finally {
|
|
118
147
|
release();
|
|
119
148
|
}
|
|
149
|
+
|
|
120
150
|
const status = response.statusCode ?? 0;
|
|
151
|
+
|
|
121
152
|
if (![429, 503].includes(status) || attempt >= MAX_RETRIES) return response;
|
|
122
153
|
// The retry decision uses headers only. Drop an untrusted error body so it cannot
|
|
123
154
|
// consume unbounded bandwidth while the caller is backing off.
|
|
@@ -146,10 +177,13 @@ export async function requestFollowingRedirects(
|
|
|
146
177
|
for (let redirects = 0; redirects <= FETCH_MAX_REDIRECTS; redirects += 1) {
|
|
147
178
|
const response = await coordinatedRequest(target, signal, requestHeaders, dependencies);
|
|
148
179
|
const status = response.statusCode ?? 0;
|
|
180
|
+
|
|
149
181
|
if (![301, 302, 303, 307, 308].includes(status)) return { target, response };
|
|
150
182
|
|
|
151
183
|
const location = responseHeader(response, "location");
|
|
184
|
+
|
|
152
185
|
if (!location) throw new Error("web_fetch received a redirect without a Location header.");
|
|
186
|
+
|
|
153
187
|
if (redirects === FETCH_MAX_REDIRECTS)
|
|
154
188
|
throw new Error("web_fetch followed too many redirects.");
|
|
155
189
|
response.resume();
|
|
@@ -157,5 +191,6 @@ export async function requestFollowingRedirects(
|
|
|
157
191
|
// Validators describe the originally cached resource and must not leak to a redirect target.
|
|
158
192
|
requestHeaders = {};
|
|
159
193
|
}
|
|
194
|
+
|
|
160
195
|
throw new Error("web_fetch followed too many redirects.");
|
|
161
196
|
}
|
package/src/network-transport.ts
CHANGED
|
@@ -20,6 +20,7 @@ const encoder = new TextEncoder();
|
|
|
20
20
|
function abortedError(): Error {
|
|
21
21
|
const error = new Error("Operation aborted.");
|
|
22
22
|
error.name = "AbortError";
|
|
23
|
+
|
|
23
24
|
return error;
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -39,6 +40,7 @@ function responseTooLargeMessage(
|
|
|
39
40
|
const size = sizeIsExact
|
|
40
41
|
? `is ${formatSize(receivedBytes)}`
|
|
41
42
|
: `has reached at least ${formatSize(receivedBytes)}`;
|
|
43
|
+
|
|
42
44
|
return [
|
|
43
45
|
`web_fetch response ${size}, exceeding the ${formatSize(maxBytes)} raw download limit.`,
|
|
44
46
|
"maxCharacters only controls returned output.",
|
|
@@ -63,7 +65,9 @@ async function requestOnce(
|
|
|
63
65
|
headers: Readonly<Record<string, string>>,
|
|
64
66
|
): Promise<IncomingMessage> {
|
|
65
67
|
const family = isIP(address);
|
|
68
|
+
|
|
66
69
|
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
|
|
70
|
+
|
|
67
71
|
const lookup: LookupFunction = (_hostname, options, callback) => {
|
|
68
72
|
// Match dns.lookup's asynchronous callback contract. Calling back inline
|
|
69
73
|
// lets an immediately unreachable address emit a socket error before
|
|
@@ -73,18 +77,24 @@ async function requestOnce(
|
|
|
73
77
|
else callback(null, address, family);
|
|
74
78
|
});
|
|
75
79
|
};
|
|
80
|
+
|
|
76
81
|
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
82
|
+
|
|
77
83
|
return await new Promise((resolve, reject) => {
|
|
78
84
|
const controller = new AbortController();
|
|
79
85
|
let attemptExpired = false;
|
|
86
|
+
|
|
80
87
|
const timer = setTimeout(() => {
|
|
81
88
|
attemptExpired = true;
|
|
82
89
|
controller.abort();
|
|
83
90
|
}, attemptTimeoutMs);
|
|
91
|
+
|
|
84
92
|
const forwardAbort = () => controller.abort();
|
|
93
|
+
|
|
85
94
|
if (signal.aborted) controller.abort();
|
|
86
95
|
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
87
96
|
let settled = false;
|
|
97
|
+
|
|
88
98
|
const finish = (callback: () => void) => {
|
|
89
99
|
if (settled) return;
|
|
90
100
|
settled = true;
|
|
@@ -92,8 +102,10 @@ async function requestOnce(
|
|
|
92
102
|
signal.removeEventListener("abort", forwardAbort);
|
|
93
103
|
callback();
|
|
94
104
|
};
|
|
105
|
+
|
|
95
106
|
const unreachableError = () =>
|
|
96
107
|
new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`);
|
|
108
|
+
|
|
97
109
|
const outgoing = request(
|
|
98
110
|
target.url,
|
|
99
111
|
{
|
|
@@ -114,6 +126,7 @@ async function requestOnce(
|
|
|
114
126
|
} else finish(() => resolve(response));
|
|
115
127
|
},
|
|
116
128
|
);
|
|
129
|
+
|
|
117
130
|
outgoing.once("error", (error) => {
|
|
118
131
|
// The per-attempt deadline surfaces as a raw AbortError from the HTTP
|
|
119
132
|
// client; report it as an unreachable address instead so the next
|
|
@@ -148,14 +161,17 @@ export async function requestPinned(
|
|
|
148
161
|
const attemptTimeoutMs = options.attemptTimeoutMs ?? CONNECT_ATTEMPT_TIMEOUT_MS;
|
|
149
162
|
const addresses = target.addresses?.length ? target.addresses : [target.address];
|
|
150
163
|
let lastError: unknown;
|
|
164
|
+
|
|
151
165
|
for (const address of addresses) {
|
|
152
166
|
try {
|
|
153
167
|
return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
|
|
154
168
|
} catch (error) {
|
|
155
169
|
lastError = error;
|
|
170
|
+
|
|
156
171
|
if (signal.aborted) throw error;
|
|
157
172
|
}
|
|
158
173
|
}
|
|
174
|
+
|
|
159
175
|
throw lastError ?? new Error("web_fetch could not connect.");
|
|
160
176
|
}
|
|
161
177
|
|
|
@@ -168,6 +184,7 @@ export async function requestPinned(
|
|
|
168
184
|
*/
|
|
169
185
|
export function responseHeader(response: IncomingMessage, name: string): string | undefined {
|
|
170
186
|
const value = response.headers[name];
|
|
187
|
+
|
|
171
188
|
return Array.isArray(value) ? value[0] : value;
|
|
172
189
|
}
|
|
173
190
|
|
|
@@ -189,27 +206,33 @@ export async function readResponseBytes(
|
|
|
189
206
|
signal?: AbortSignal,
|
|
190
207
|
): Promise<Uint8Array> {
|
|
191
208
|
const declared = Number(responseHeader(response, "content-length"));
|
|
209
|
+
|
|
192
210
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
193
211
|
response.destroy();
|
|
194
212
|
throw new Error(responseTooLargeMessage(declared, maxBytes, true));
|
|
195
213
|
}
|
|
214
|
+
|
|
196
215
|
// Once response headers arrive the connect deadline and caller signal are no
|
|
197
216
|
// longer wired to the socket, so a stalled body would otherwise hang the
|
|
198
217
|
// fetch forever. Keep the caller signal attached for the whole body read and
|
|
199
218
|
// drop the socket when it fires.
|
|
200
219
|
const forwardAbort = () => response.destroy();
|
|
220
|
+
|
|
201
221
|
if (signal?.aborted) response.destroy();
|
|
202
222
|
else signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
203
223
|
const chunks: Uint8Array[] = [];
|
|
204
224
|
let total = 0;
|
|
225
|
+
|
|
205
226
|
try {
|
|
206
227
|
for await (const value of response) {
|
|
207
228
|
const chunk = value instanceof Uint8Array ? value : encoder.encode(value);
|
|
208
229
|
total += chunk.byteLength;
|
|
230
|
+
|
|
209
231
|
if (total > maxBytes) {
|
|
210
232
|
response.destroy();
|
|
211
233
|
throw new Error(responseTooLargeMessage(total, maxBytes, false));
|
|
212
234
|
}
|
|
235
|
+
|
|
213
236
|
chunks.push(chunk);
|
|
214
237
|
}
|
|
215
238
|
} catch (error) {
|
|
@@ -218,15 +241,18 @@ export async function readResponseBytes(
|
|
|
218
241
|
} finally {
|
|
219
242
|
signal?.removeEventListener("abort", forwardAbort);
|
|
220
243
|
}
|
|
244
|
+
|
|
221
245
|
// Destroying the socket can end the stream without an error; detect a
|
|
222
246
|
// mid-read abort here as well so truncated bodies never look complete.
|
|
223
247
|
if (signal?.aborted) throw abortedError();
|
|
224
248
|
const output = new Uint8Array(total);
|
|
225
249
|
let offset = 0;
|
|
250
|
+
|
|
226
251
|
for (const chunk of chunks) {
|
|
227
252
|
output.set(chunk, offset);
|
|
228
253
|
offset += chunk.byteLength;
|
|
229
254
|
}
|
|
255
|
+
|
|
230
256
|
return output;
|
|
231
257
|
}
|
|
232
258
|
|
|
@@ -242,6 +268,7 @@ export async function readResponseBytes(
|
|
|
242
268
|
*/
|
|
243
269
|
export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
244
270
|
const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
|
|
271
|
+
|
|
245
272
|
try {
|
|
246
273
|
return new TextDecoder(charset || "utf-8").decode(bytes);
|
|
247
274
|
} catch {
|