@j0hanz/superfetch 2.0.1 → 2.1.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/README.md +120 -38
- package/dist/cache.d.ts +42 -0
- package/dist/cache.js +565 -0
- package/dist/config/env-parsers.d.ts +1 -0
- package/dist/config/env-parsers.js +12 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +10 -3
- package/dist/config/types/content.d.ts +1 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +261 -0
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.js +32 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +28 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +910 -0
- package/dist/http/base-middleware.d.ts +7 -0
- package/dist/http/base-middleware.js +143 -0
- package/dist/http/cors.d.ts +0 -5
- package/dist/http/cors.js +0 -6
- package/dist/http/download-routes.js +6 -2
- package/dist/http/error-handler.d.ts +2 -0
- package/dist/http/error-handler.js +55 -0
- package/dist/http/mcp-routes.js +2 -2
- package/dist/http/mcp-sessions.d.ts +3 -5
- package/dist/http/mcp-sessions.js +8 -8
- package/dist/http/server-tuning.d.ts +9 -0
- package/dist/http/server-tuning.js +45 -0
- package/dist/http/server.d.ts +0 -10
- package/dist/http/server.js +33 -333
- package/dist/http.d.ts +78 -0
- package/dist/http.js +1437 -0
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +94 -0
- package/dist/observability.d.ts +16 -0
- package/dist/observability.js +78 -0
- package/dist/server.js +20 -5
- package/dist/services/cache.d.ts +1 -1
- package/dist/services/context.d.ts +2 -0
- package/dist/services/context.js +3 -0
- package/dist/services/extractor.d.ts +1 -0
- package/dist/services/extractor.js +28 -2
- package/dist/services/fetcher.d.ts +2 -0
- package/dist/services/fetcher.js +35 -14
- package/dist/services/logger.js +4 -1
- package/dist/services/telemetry.d.ts +19 -0
- package/dist/services/telemetry.js +43 -0
- package/dist/services/transform-worker-pool.d.ts +10 -3
- package/dist/services/transform-worker-pool.js +213 -184
- package/dist/tools/handlers/fetch-url.tool.js +8 -6
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +13 -1
- package/dist/tools/schemas.d.ts +2 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/utils/content-transform-core.d.ts +5 -0
- package/dist/tools/utils/content-transform-core.js +180 -0
- package/dist/tools/utils/content-transform-workers.d.ts +1 -0
- package/dist/tools/utils/content-transform-workers.js +1 -0
- package/dist/tools/utils/content-transform.d.ts +3 -5
- package/dist/tools/utils/content-transform.js +35 -148
- package/dist/tools/utils/raw-markdown.js +15 -1
- package/dist/tools.d.ts +104 -0
- package/dist/tools.js +421 -0
- package/dist/transform.d.ts +69 -0
- package/dist/transform.js +1509 -0
- package/dist/transformers/markdown.d.ts +4 -1
- package/dist/transformers/markdown.js +182 -53
- package/dist/utils/cancellation.d.ts +1 -0
- package/dist/utils/cancellation.js +18 -0
- package/dist/utils/code-language.d.ts +0 -9
- package/dist/utils/code-language.js +5 -5
- package/dist/utils/host-normalizer.d.ts +1 -0
- package/dist/utils/host-normalizer.js +37 -0
- package/dist/utils/url-redactor.d.ts +1 -0
- package/dist/utils/url-redactor.js +13 -0
- package/dist/utils/url-validator.js +8 -5
- package/dist/workers/transform-worker.js +82 -38
- package/package.json +8 -7
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import diagnosticsChannel from 'node:diagnostics_channel';
|
|
3
|
+
import dns from 'node:dns';
|
|
4
|
+
import { BlockList, isIP } from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
7
|
+
import { Agent } from 'undici';
|
|
8
|
+
import { config } from './config.js';
|
|
9
|
+
import { createErrorWithCode, FetchError, isSystemError } from './errors.js';
|
|
10
|
+
import { getOperationId, getRequestId, logDebug, logError, logWarn, redactUrl, } from './observability.js';
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null;
|
|
13
|
+
}
|
|
14
|
+
function buildIpv4(parts) {
|
|
15
|
+
return parts.join('.');
|
|
16
|
+
}
|
|
17
|
+
function buildIpv6(parts) {
|
|
18
|
+
return parts.map(String).join(':');
|
|
19
|
+
}
|
|
20
|
+
const BLOCK_LIST = new BlockList();
|
|
21
|
+
const IPV6_ZERO = buildIpv6([0, 0, 0, 0, 0, 0, 0, 0]);
|
|
22
|
+
const IPV6_LOOPBACK = buildIpv6([0, 0, 0, 0, 0, 0, 0, 1]);
|
|
23
|
+
const IPV6_64_FF9B = buildIpv6(['64', 'ff9b', 0, 0, 0, 0, 0, 0]);
|
|
24
|
+
const IPV6_64_FF9B_1 = buildIpv6(['64', 'ff9b', 1, 0, 0, 0, 0, 0]);
|
|
25
|
+
const IPV6_2001 = buildIpv6(['2001', 0, 0, 0, 0, 0, 0, 0]);
|
|
26
|
+
const IPV6_2002 = buildIpv6(['2002', 0, 0, 0, 0, 0, 0, 0]);
|
|
27
|
+
const IPV6_FC00 = buildIpv6(['fc00', 0, 0, 0, 0, 0, 0, 0]);
|
|
28
|
+
const IPV6_FE80 = buildIpv6(['fe80', 0, 0, 0, 0, 0, 0, 0]);
|
|
29
|
+
const IPV6_FF00 = buildIpv6(['ff00', 0, 0, 0, 0, 0, 0, 0]);
|
|
30
|
+
const BLOCKED_IPV4_SUBNETS = [
|
|
31
|
+
{ subnet: buildIpv4([0, 0, 0, 0]), prefix: 8 },
|
|
32
|
+
{ subnet: buildIpv4([10, 0, 0, 0]), prefix: 8 },
|
|
33
|
+
{ subnet: buildIpv4([100, 64, 0, 0]), prefix: 10 },
|
|
34
|
+
{ subnet: buildIpv4([127, 0, 0, 0]), prefix: 8 },
|
|
35
|
+
{ subnet: buildIpv4([169, 254, 0, 0]), prefix: 16 },
|
|
36
|
+
{ subnet: buildIpv4([172, 16, 0, 0]), prefix: 12 },
|
|
37
|
+
{ subnet: buildIpv4([192, 168, 0, 0]), prefix: 16 },
|
|
38
|
+
{ subnet: buildIpv4([224, 0, 0, 0]), prefix: 4 },
|
|
39
|
+
{ subnet: buildIpv4([240, 0, 0, 0]), prefix: 4 },
|
|
40
|
+
];
|
|
41
|
+
const BLOCKED_IPV6_SUBNETS = [
|
|
42
|
+
{ subnet: IPV6_ZERO, prefix: 128 },
|
|
43
|
+
{ subnet: IPV6_LOOPBACK, prefix: 128 },
|
|
44
|
+
{ subnet: IPV6_64_FF9B, prefix: 96 },
|
|
45
|
+
{ subnet: IPV6_64_FF9B_1, prefix: 48 },
|
|
46
|
+
{ subnet: IPV6_2001, prefix: 32 },
|
|
47
|
+
{ subnet: IPV6_2002, prefix: 16 },
|
|
48
|
+
{ subnet: IPV6_FC00, prefix: 7 },
|
|
49
|
+
{ subnet: IPV6_FE80, prefix: 10 },
|
|
50
|
+
{ subnet: IPV6_FF00, prefix: 8 },
|
|
51
|
+
];
|
|
52
|
+
for (const entry of BLOCKED_IPV4_SUBNETS) {
|
|
53
|
+
BLOCK_LIST.addSubnet(entry.subnet, entry.prefix, 'ipv4');
|
|
54
|
+
}
|
|
55
|
+
for (const entry of BLOCKED_IPV6_SUBNETS) {
|
|
56
|
+
BLOCK_LIST.addSubnet(entry.subnet, entry.prefix, 'ipv6');
|
|
57
|
+
}
|
|
58
|
+
function matchesBlockedIpPatterns(resolvedIp) {
|
|
59
|
+
for (const pattern of config.security.blockedIpPatterns) {
|
|
60
|
+
if (pattern.test(resolvedIp)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
export function isBlockedIp(ip) {
|
|
67
|
+
if (config.security.blockedHosts.has(ip)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const ipType = resolveIpType(ip);
|
|
71
|
+
if (!ipType)
|
|
72
|
+
return false;
|
|
73
|
+
const normalizedIp = ip.toLowerCase();
|
|
74
|
+
if (isBlockedByList(normalizedIp, ipType))
|
|
75
|
+
return true;
|
|
76
|
+
return matchesBlockedIpPatterns(normalizedIp);
|
|
77
|
+
}
|
|
78
|
+
function resolveIpType(ip) {
|
|
79
|
+
const ipType = isIP(ip);
|
|
80
|
+
return ipType === 4 || ipType === 6 ? ipType : null;
|
|
81
|
+
}
|
|
82
|
+
function isBlockedByList(ip, ipType) {
|
|
83
|
+
if (ipType === 4) {
|
|
84
|
+
return BLOCK_LIST.check(ip, 'ipv4');
|
|
85
|
+
}
|
|
86
|
+
return BLOCK_LIST.check(ip, 'ipv6');
|
|
87
|
+
}
|
|
88
|
+
export function normalizeUrl(urlString) {
|
|
89
|
+
const trimmedUrl = requireTrimmedUrl(urlString);
|
|
90
|
+
assertUrlLength(trimmedUrl);
|
|
91
|
+
const url = parseUrl(trimmedUrl);
|
|
92
|
+
assertHttpProtocol(url);
|
|
93
|
+
assertNoCredentials(url);
|
|
94
|
+
const hostname = normalizeHostname(url);
|
|
95
|
+
assertHostnameAllowed(hostname);
|
|
96
|
+
// Canonicalize hostname to avoid trailing-dot variants and keep url.href consistent.
|
|
97
|
+
url.hostname = hostname;
|
|
98
|
+
return { normalizedUrl: url.href, hostname };
|
|
99
|
+
}
|
|
100
|
+
export function validateAndNormalizeUrl(urlString) {
|
|
101
|
+
return normalizeUrl(urlString).normalizedUrl;
|
|
102
|
+
}
|
|
103
|
+
const VALIDATION_ERROR_CODE = 'VALIDATION_ERROR';
|
|
104
|
+
function createValidationError(message) {
|
|
105
|
+
return createErrorWithCode(message, VALIDATION_ERROR_CODE);
|
|
106
|
+
}
|
|
107
|
+
function requireTrimmedUrl(urlString) {
|
|
108
|
+
if (!urlString || typeof urlString !== 'string') {
|
|
109
|
+
throw createValidationError('URL is required');
|
|
110
|
+
}
|
|
111
|
+
const trimmedUrl = urlString.trim();
|
|
112
|
+
if (!trimmedUrl) {
|
|
113
|
+
throw createValidationError('URL cannot be empty');
|
|
114
|
+
}
|
|
115
|
+
return trimmedUrl;
|
|
116
|
+
}
|
|
117
|
+
function assertUrlLength(url) {
|
|
118
|
+
if (url.length <= config.constants.maxUrlLength)
|
|
119
|
+
return;
|
|
120
|
+
throw createValidationError(`URL exceeds maximum length of ${config.constants.maxUrlLength} characters`);
|
|
121
|
+
}
|
|
122
|
+
function parseUrl(urlString) {
|
|
123
|
+
if (!URL.canParse(urlString)) {
|
|
124
|
+
throw createValidationError('Invalid URL format');
|
|
125
|
+
}
|
|
126
|
+
return new URL(urlString);
|
|
127
|
+
}
|
|
128
|
+
function assertHttpProtocol(url) {
|
|
129
|
+
if (url.protocol === 'http:' || url.protocol === 'https:')
|
|
130
|
+
return;
|
|
131
|
+
throw createValidationError(`Invalid protocol: ${url.protocol}. Only http: and https: are allowed`);
|
|
132
|
+
}
|
|
133
|
+
function assertNoCredentials(url) {
|
|
134
|
+
if (!url.username && !url.password)
|
|
135
|
+
return;
|
|
136
|
+
throw createValidationError('URLs with embedded credentials are not allowed');
|
|
137
|
+
}
|
|
138
|
+
function normalizeHostname(url) {
|
|
139
|
+
let hostname = url.hostname.toLowerCase();
|
|
140
|
+
while (hostname.endsWith('.')) {
|
|
141
|
+
hostname = hostname.slice(0, -1);
|
|
142
|
+
}
|
|
143
|
+
if (!hostname) {
|
|
144
|
+
throw createValidationError('URL must have a valid hostname');
|
|
145
|
+
}
|
|
146
|
+
return hostname;
|
|
147
|
+
}
|
|
148
|
+
const BLOCKED_HOST_SUFFIXES = ['.local', '.internal'];
|
|
149
|
+
function assertHostnameAllowed(hostname) {
|
|
150
|
+
assertNotBlockedHost(hostname);
|
|
151
|
+
assertNotBlockedIp(hostname);
|
|
152
|
+
assertNotBlockedHostnameSuffix(hostname);
|
|
153
|
+
}
|
|
154
|
+
function assertNotBlockedHost(hostname) {
|
|
155
|
+
if (!config.security.blockedHosts.has(hostname))
|
|
156
|
+
return;
|
|
157
|
+
throw createValidationError(`Blocked host: ${hostname}. Internal hosts are not allowed`);
|
|
158
|
+
}
|
|
159
|
+
function assertNotBlockedIp(hostname) {
|
|
160
|
+
if (!isBlockedIp(hostname))
|
|
161
|
+
return;
|
|
162
|
+
throw createValidationError(`Blocked IP range: ${hostname}. Private IPs are not allowed`);
|
|
163
|
+
}
|
|
164
|
+
function assertNotBlockedHostnameSuffix(hostname) {
|
|
165
|
+
if (!matchesBlockedSuffix(hostname))
|
|
166
|
+
return;
|
|
167
|
+
throw createValidationError(`Blocked hostname pattern: ${hostname}. Internal domain suffixes are not allowed`);
|
|
168
|
+
}
|
|
169
|
+
function matchesBlockedSuffix(hostname) {
|
|
170
|
+
return BLOCKED_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
|
|
171
|
+
}
|
|
172
|
+
const GITHUB_BLOB_RULE = {
|
|
173
|
+
name: 'github',
|
|
174
|
+
pattern: /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/i,
|
|
175
|
+
transform: (match) => {
|
|
176
|
+
const owner = match[1] ?? '';
|
|
177
|
+
const repo = match[2] ?? '';
|
|
178
|
+
const branch = match[3] ?? '';
|
|
179
|
+
const path = match[4] ?? '';
|
|
180
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
const GITHUB_GIST_RULE = {
|
|
184
|
+
name: 'github-gist',
|
|
185
|
+
pattern: /^https?:\/\/gist\.github\.com\/([^/]+)\/([a-f0-9]+)(?:#file-(.+)|\/raw\/([^/]+))?$/i,
|
|
186
|
+
transform: (match) => {
|
|
187
|
+
const user = match[1] ?? '';
|
|
188
|
+
const gistId = match[2] ?? '';
|
|
189
|
+
const hashFile = match[3];
|
|
190
|
+
const rawFile = match[4];
|
|
191
|
+
const filename = rawFile ?? hashFile?.replace(/-/g, '.');
|
|
192
|
+
const filePath = filename ? `/${filename}` : '';
|
|
193
|
+
return `https://gist.githubusercontent.com/${user}/${gistId}/raw${filePath}`;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
const GITLAB_BLOB_RULE = {
|
|
197
|
+
name: 'gitlab',
|
|
198
|
+
pattern: /^(https?:\/\/(?:[^/]+\.)?gitlab\.com\/[^/]+\/[^/]+)\/-\/blob\/([^/]+)\/(.+)$/i,
|
|
199
|
+
transform: (match) => {
|
|
200
|
+
const baseUrl = match[1] ?? '';
|
|
201
|
+
const branch = match[2] ?? '';
|
|
202
|
+
const path = match[3] ?? '';
|
|
203
|
+
return `${baseUrl}/-/raw/${branch}/${path}`;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const BITBUCKET_SRC_RULE = {
|
|
207
|
+
name: 'bitbucket',
|
|
208
|
+
pattern: /^(https?:\/\/(?:www\.)?bitbucket\.org\/[^/]+\/[^/]+)\/src\/([^/]+)\/(.+)$/i,
|
|
209
|
+
transform: (match) => {
|
|
210
|
+
const baseUrl = match[1] ?? '';
|
|
211
|
+
const branch = match[2] ?? '';
|
|
212
|
+
const path = match[3] ?? '';
|
|
213
|
+
return `${baseUrl}/raw/${branch}/${path}`;
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const TRANSFORM_RULES = [
|
|
217
|
+
GITHUB_BLOB_RULE,
|
|
218
|
+
GITHUB_GIST_RULE,
|
|
219
|
+
GITLAB_BLOB_RULE,
|
|
220
|
+
BITBUCKET_SRC_RULE,
|
|
221
|
+
];
|
|
222
|
+
function isRawUrl(url) {
|
|
223
|
+
const lowerUrl = url.toLowerCase();
|
|
224
|
+
return (lowerUrl.includes('raw.githubusercontent.com') ||
|
|
225
|
+
lowerUrl.includes('gist.githubusercontent.com') ||
|
|
226
|
+
lowerUrl.includes('/-/raw/') ||
|
|
227
|
+
/bitbucket\.org\/[^/]+\/[^/]+\/raw\//.test(lowerUrl));
|
|
228
|
+
}
|
|
229
|
+
function getUrlWithoutParams(url) {
|
|
230
|
+
const hashIndex = url.indexOf('#');
|
|
231
|
+
const queryIndex = url.indexOf('?');
|
|
232
|
+
let endIndex = url.length;
|
|
233
|
+
if (queryIndex !== -1) {
|
|
234
|
+
if (hashIndex !== -1) {
|
|
235
|
+
endIndex = Math.min(queryIndex, hashIndex);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
endIndex = queryIndex;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else if (hashIndex !== -1) {
|
|
242
|
+
endIndex = hashIndex;
|
|
243
|
+
}
|
|
244
|
+
const hash = hashIndex !== -1 ? url.slice(hashIndex) : '';
|
|
245
|
+
return {
|
|
246
|
+
base: url.slice(0, endIndex),
|
|
247
|
+
hash,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function resolveUrlToMatch(rule, base, hash) {
|
|
251
|
+
if (rule.name !== 'github-gist')
|
|
252
|
+
return base;
|
|
253
|
+
if (!hash.startsWith('#file-'))
|
|
254
|
+
return base;
|
|
255
|
+
return base + hash;
|
|
256
|
+
}
|
|
257
|
+
function applyTransformRules(base, hash) {
|
|
258
|
+
for (const rule of TRANSFORM_RULES) {
|
|
259
|
+
const urlToMatch = resolveUrlToMatch(rule, base, hash);
|
|
260
|
+
const match = rule.pattern.exec(urlToMatch);
|
|
261
|
+
if (match) {
|
|
262
|
+
return { url: rule.transform(match), platform: rule.name };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
export function transformToRawUrl(url) {
|
|
268
|
+
if (!url)
|
|
269
|
+
return { url, transformed: false };
|
|
270
|
+
if (isRawUrl(url)) {
|
|
271
|
+
return { url, transformed: false };
|
|
272
|
+
}
|
|
273
|
+
const { base, hash } = getUrlWithoutParams(url);
|
|
274
|
+
const result = applyTransformRules(base, hash);
|
|
275
|
+
if (!result)
|
|
276
|
+
return { url, transformed: false };
|
|
277
|
+
logDebug('URL transformed to raw content URL', {
|
|
278
|
+
platform: result.platform,
|
|
279
|
+
original: url.substring(0, 100),
|
|
280
|
+
transformed: result.url.substring(0, 100),
|
|
281
|
+
});
|
|
282
|
+
return {
|
|
283
|
+
url: result.url,
|
|
284
|
+
transformed: true,
|
|
285
|
+
platform: result.platform,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const RAW_TEXT_EXTENSIONS = new Set([
|
|
289
|
+
'.md',
|
|
290
|
+
'.markdown',
|
|
291
|
+
'.txt',
|
|
292
|
+
'.json',
|
|
293
|
+
'.yaml',
|
|
294
|
+
'.yml',
|
|
295
|
+
'.toml',
|
|
296
|
+
'.xml',
|
|
297
|
+
'.csv',
|
|
298
|
+
'.rst',
|
|
299
|
+
'.adoc',
|
|
300
|
+
'.org',
|
|
301
|
+
]);
|
|
302
|
+
export function isRawTextContentUrl(url) {
|
|
303
|
+
if (!url)
|
|
304
|
+
return false;
|
|
305
|
+
if (isRawUrl(url))
|
|
306
|
+
return true;
|
|
307
|
+
const { base } = getUrlWithoutParams(url);
|
|
308
|
+
const lowerBase = base.toLowerCase();
|
|
309
|
+
return hasKnownRawTextExtension(lowerBase);
|
|
310
|
+
}
|
|
311
|
+
function hasKnownRawTextExtension(urlBaseLower) {
|
|
312
|
+
for (const ext of RAW_TEXT_EXTENSIONS) {
|
|
313
|
+
if (urlBaseLower.endsWith(ext))
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const DNS_LOOKUP_TIMEOUT_MS = 5000;
|
|
319
|
+
function normalizeLookupResults(addresses, family) {
|
|
320
|
+
if (Array.isArray(addresses)) {
|
|
321
|
+
return addresses;
|
|
322
|
+
}
|
|
323
|
+
return [{ address: addresses, family: family ?? 4 }];
|
|
324
|
+
}
|
|
325
|
+
function findBlockedIpError(list, hostname) {
|
|
326
|
+
for (const addr of list) {
|
|
327
|
+
const ip = typeof addr === 'string' ? addr : addr.address;
|
|
328
|
+
if (!isBlockedIp(ip)) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
return createErrorWithCode(`Blocked IP detected for ${hostname}`, 'EBLOCKED');
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
function findInvalidFamilyError(list, hostname) {
|
|
336
|
+
for (const addr of list) {
|
|
337
|
+
const family = typeof addr === 'string' ? 0 : addr.family;
|
|
338
|
+
if (family === 4 || family === 6)
|
|
339
|
+
continue;
|
|
340
|
+
return createErrorWithCode(`Invalid address family returned for ${hostname}`, 'EINVAL');
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
function createNoDnsResultsError(hostname) {
|
|
345
|
+
return createErrorWithCode(`No DNS results returned for ${hostname}`, 'ENODATA');
|
|
346
|
+
}
|
|
347
|
+
function createEmptySelection(hostname) {
|
|
348
|
+
return {
|
|
349
|
+
error: createNoDnsResultsError(hostname),
|
|
350
|
+
fallback: [],
|
|
351
|
+
address: [],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function selectLookupResult(list, useAll, hostname) {
|
|
355
|
+
if (list.length === 0)
|
|
356
|
+
return createEmptySelection(hostname);
|
|
357
|
+
if (useAll)
|
|
358
|
+
return { address: list, fallback: list };
|
|
359
|
+
const first = list.at(0);
|
|
360
|
+
if (!first)
|
|
361
|
+
return createEmptySelection(hostname);
|
|
362
|
+
return {
|
|
363
|
+
address: first.address,
|
|
364
|
+
family: first.family,
|
|
365
|
+
fallback: list,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function findLookupError(list, hostname) {
|
|
369
|
+
return (findInvalidFamilyError(list, hostname) ?? findBlockedIpError(list, hostname));
|
|
370
|
+
}
|
|
371
|
+
function handleLookupResult(error, addresses, hostname, resolvedFamily, useAll, callback) {
|
|
372
|
+
if (error) {
|
|
373
|
+
callback(error, addresses);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const list = normalizeLookupResults(addresses, resolvedFamily);
|
|
377
|
+
const lookupError = findLookupError(list, hostname);
|
|
378
|
+
if (lookupError) {
|
|
379
|
+
callback(lookupError, list);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const selection = selectLookupResult(list, useAll, hostname);
|
|
383
|
+
if (selection.error) {
|
|
384
|
+
callback(selection.error, selection.fallback);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
callback(null, selection.address, selection.family);
|
|
388
|
+
}
|
|
389
|
+
function resolveDns(hostname, options, callback) {
|
|
390
|
+
const { normalizedOptions, useAll, resolvedFamily } = buildLookupContext(options);
|
|
391
|
+
const lookupOptions = buildLookupOptions(normalizedOptions);
|
|
392
|
+
const timeout = createLookupTimeout(hostname, callback);
|
|
393
|
+
const safeCallback = wrapLookupCallback(callback, timeout);
|
|
394
|
+
dns.lookup(hostname, lookupOptions, createLookupCallback(hostname, resolvedFamily, useAll, safeCallback));
|
|
395
|
+
}
|
|
396
|
+
function normalizeLookupOptions(options) {
|
|
397
|
+
return typeof options === 'number' ? { family: options } : options;
|
|
398
|
+
}
|
|
399
|
+
function buildLookupContext(options) {
|
|
400
|
+
const normalizedOptions = normalizeLookupOptions(options);
|
|
401
|
+
return {
|
|
402
|
+
normalizedOptions,
|
|
403
|
+
useAll: Boolean(normalizedOptions.all),
|
|
404
|
+
resolvedFamily: resolveFamily(normalizedOptions.family),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const DEFAULT_DNS_ORDER = 'verbatim';
|
|
408
|
+
function resolveResultOrder(options) {
|
|
409
|
+
if (options.order)
|
|
410
|
+
return options.order;
|
|
411
|
+
const legacyVerbatim = getLegacyVerbatim(options);
|
|
412
|
+
if (legacyVerbatim !== undefined) {
|
|
413
|
+
return legacyVerbatim ? 'verbatim' : 'ipv4first';
|
|
414
|
+
}
|
|
415
|
+
return DEFAULT_DNS_ORDER;
|
|
416
|
+
}
|
|
417
|
+
function getLegacyVerbatim(options) {
|
|
418
|
+
if (isRecord(options)) {
|
|
419
|
+
const { verbatim } = options;
|
|
420
|
+
return typeof verbatim === 'boolean' ? verbatim : undefined;
|
|
421
|
+
}
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
function buildLookupOptions(normalizedOptions) {
|
|
425
|
+
return {
|
|
426
|
+
family: normalizedOptions.family,
|
|
427
|
+
hints: normalizedOptions.hints,
|
|
428
|
+
all: true,
|
|
429
|
+
order: resolveResultOrder(normalizedOptions),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function createLookupCallback(hostname, resolvedFamily, useAll, callback) {
|
|
433
|
+
return (err, addresses) => {
|
|
434
|
+
handleLookupResult(err, addresses, hostname, resolvedFamily, useAll, callback);
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function resolveFamily(family) {
|
|
438
|
+
if (family === 'IPv4')
|
|
439
|
+
return 4;
|
|
440
|
+
if (family === 'IPv6')
|
|
441
|
+
return 6;
|
|
442
|
+
return family;
|
|
443
|
+
}
|
|
444
|
+
function createLookupTimeout(hostname, callback) {
|
|
445
|
+
let done = false;
|
|
446
|
+
const timer = setTimeout(() => {
|
|
447
|
+
if (done)
|
|
448
|
+
return;
|
|
449
|
+
done = true;
|
|
450
|
+
callback(createErrorWithCode(`DNS lookup timed out for ${hostname}`, 'ETIMEOUT'), []);
|
|
451
|
+
}, DNS_LOOKUP_TIMEOUT_MS);
|
|
452
|
+
timer.unref();
|
|
453
|
+
return {
|
|
454
|
+
isDone: () => done,
|
|
455
|
+
markDone: () => {
|
|
456
|
+
done = true;
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function wrapLookupCallback(callback, timeout) {
|
|
462
|
+
return (err, address, family) => {
|
|
463
|
+
if (timeout.isDone())
|
|
464
|
+
return;
|
|
465
|
+
timeout.markDone();
|
|
466
|
+
callback(err, address, family);
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function getAgentOptions() {
|
|
470
|
+
const cpuCount = os.availableParallelism();
|
|
471
|
+
return {
|
|
472
|
+
keepAliveTimeout: 60000,
|
|
473
|
+
connections: Math.max(cpuCount * 2, 25),
|
|
474
|
+
pipelining: 1,
|
|
475
|
+
connect: { lookup: resolveDns },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
export const dispatcher = new Agent(getAgentOptions());
|
|
479
|
+
export function destroyAgents() {
|
|
480
|
+
void dispatcher.close();
|
|
481
|
+
}
|
|
482
|
+
function parseRetryAfter(header) {
|
|
483
|
+
if (!header)
|
|
484
|
+
return 60;
|
|
485
|
+
const parsed = parseInt(header, 10);
|
|
486
|
+
return Number.isNaN(parsed) ? 60 : parsed;
|
|
487
|
+
}
|
|
488
|
+
function createCanceledError(url) {
|
|
489
|
+
return new FetchError('Request was canceled', url, 499, {
|
|
490
|
+
reason: 'aborted',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
function createTimeoutError(url, timeoutMs) {
|
|
494
|
+
return new FetchError(`Request timeout after ${timeoutMs}ms`, url, 504, {
|
|
495
|
+
timeout: timeoutMs,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
function createRateLimitError(url, headerValue) {
|
|
499
|
+
const retryAfter = parseRetryAfter(headerValue);
|
|
500
|
+
return new FetchError('Too many requests', url, 429, { retryAfter });
|
|
501
|
+
}
|
|
502
|
+
function createHttpError(url, status, statusText) {
|
|
503
|
+
return new FetchError(`HTTP ${status}: ${statusText}`, url, status);
|
|
504
|
+
}
|
|
505
|
+
function createNetworkError(url, message) {
|
|
506
|
+
const details = message ? { message } : undefined;
|
|
507
|
+
return new FetchError(`Network error: Could not reach ${url}`, url, undefined, details ?? {});
|
|
508
|
+
}
|
|
509
|
+
function createUnknownError(url, message) {
|
|
510
|
+
return new FetchError(message, url);
|
|
511
|
+
}
|
|
512
|
+
function isAbortError(error) {
|
|
513
|
+
return (error instanceof Error &&
|
|
514
|
+
(error.name === 'AbortError' || error.name === 'TimeoutError'));
|
|
515
|
+
}
|
|
516
|
+
function isTimeoutError(error) {
|
|
517
|
+
return error instanceof Error && error.name === 'TimeoutError';
|
|
518
|
+
}
|
|
519
|
+
function getRequestUrl(record) {
|
|
520
|
+
const value = record.requestUrl;
|
|
521
|
+
return typeof value === 'string' ? value : null;
|
|
522
|
+
}
|
|
523
|
+
function resolveErrorUrl(error, fallback) {
|
|
524
|
+
if (error instanceof FetchError)
|
|
525
|
+
return error.url;
|
|
526
|
+
if (!isRecord(error))
|
|
527
|
+
return fallback;
|
|
528
|
+
const requestUrl = getRequestUrl(error);
|
|
529
|
+
if (requestUrl)
|
|
530
|
+
return requestUrl;
|
|
531
|
+
return fallback;
|
|
532
|
+
}
|
|
533
|
+
function mapFetchError(error, fallbackUrl, timeoutMs) {
|
|
534
|
+
if (error instanceof FetchError)
|
|
535
|
+
return error;
|
|
536
|
+
const url = resolveErrorUrl(error, fallbackUrl);
|
|
537
|
+
if (isAbortError(error)) {
|
|
538
|
+
if (isTimeoutError(error)) {
|
|
539
|
+
return createTimeoutError(url, timeoutMs);
|
|
540
|
+
}
|
|
541
|
+
return createCanceledError(url);
|
|
542
|
+
}
|
|
543
|
+
if (error instanceof Error) {
|
|
544
|
+
return createNetworkError(url, error.message);
|
|
545
|
+
}
|
|
546
|
+
return createUnknownError(url, 'Unexpected error');
|
|
547
|
+
}
|
|
548
|
+
const fetchChannel = diagnosticsChannel.channel('superfetch.fetch');
|
|
549
|
+
function publishFetchEvent(event) {
|
|
550
|
+
if (!fetchChannel.hasSubscribers)
|
|
551
|
+
return;
|
|
552
|
+
try {
|
|
553
|
+
fetchChannel.publish(event);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// Avoid crashing the publisher if a subscriber throws.
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
export function startFetchTelemetry(url, method) {
|
|
560
|
+
const safeUrl = redactUrl(url);
|
|
561
|
+
const contextRequestId = getRequestId();
|
|
562
|
+
const operationId = getOperationId();
|
|
563
|
+
const context = {
|
|
564
|
+
requestId: randomUUID(),
|
|
565
|
+
startTime: performance.now(),
|
|
566
|
+
url: safeUrl,
|
|
567
|
+
method: method.toUpperCase(),
|
|
568
|
+
...(contextRequestId ? { contextRequestId } : {}),
|
|
569
|
+
...(operationId ? { operationId } : {}),
|
|
570
|
+
};
|
|
571
|
+
publishFetchEvent({
|
|
572
|
+
v: 1,
|
|
573
|
+
type: 'start',
|
|
574
|
+
requestId: context.requestId,
|
|
575
|
+
method: context.method,
|
|
576
|
+
url: context.url,
|
|
577
|
+
...(context.contextRequestId
|
|
578
|
+
? { contextRequestId: context.contextRequestId }
|
|
579
|
+
: {}),
|
|
580
|
+
...(context.operationId ? { operationId: context.operationId } : {}),
|
|
581
|
+
});
|
|
582
|
+
logDebug('HTTP Request', {
|
|
583
|
+
requestId: context.requestId,
|
|
584
|
+
method: context.method,
|
|
585
|
+
url: context.url,
|
|
586
|
+
...(context.contextRequestId
|
|
587
|
+
? { contextRequestId: context.contextRequestId }
|
|
588
|
+
: {}),
|
|
589
|
+
...(context.operationId ? { operationId: context.operationId } : {}),
|
|
590
|
+
});
|
|
591
|
+
return context;
|
|
592
|
+
}
|
|
593
|
+
export function recordFetchResponse(context, response, contentSize) {
|
|
594
|
+
const duration = performance.now() - context.startTime;
|
|
595
|
+
const durationLabel = `${Math.round(duration)}ms`;
|
|
596
|
+
publishFetchEvent({
|
|
597
|
+
v: 1,
|
|
598
|
+
type: 'end',
|
|
599
|
+
requestId: context.requestId,
|
|
600
|
+
status: response.status,
|
|
601
|
+
duration,
|
|
602
|
+
...(context.contextRequestId
|
|
603
|
+
? { contextRequestId: context.contextRequestId }
|
|
604
|
+
: {}),
|
|
605
|
+
...(context.operationId ? { operationId: context.operationId } : {}),
|
|
606
|
+
});
|
|
607
|
+
const contentType = response.headers.get('content-type');
|
|
608
|
+
const contentLength = response.headers.get('content-length') ??
|
|
609
|
+
(contentSize === undefined ? undefined : String(contentSize));
|
|
610
|
+
logDebug('HTTP Response', {
|
|
611
|
+
requestId: context.requestId,
|
|
612
|
+
status: response.status,
|
|
613
|
+
url: context.url,
|
|
614
|
+
duration: durationLabel,
|
|
615
|
+
...(context.contextRequestId
|
|
616
|
+
? { contextRequestId: context.contextRequestId }
|
|
617
|
+
: {}),
|
|
618
|
+
...(context.operationId ? { operationId: context.operationId } : {}),
|
|
619
|
+
...(contentType ? { contentType } : {}),
|
|
620
|
+
...(contentLength ? { size: contentLength } : {}),
|
|
621
|
+
});
|
|
622
|
+
if (duration > 5000) {
|
|
623
|
+
logWarn('Slow HTTP request detected', {
|
|
624
|
+
requestId: context.requestId,
|
|
625
|
+
url: context.url,
|
|
626
|
+
duration: durationLabel,
|
|
627
|
+
...(context.contextRequestId
|
|
628
|
+
? { contextRequestId: context.contextRequestId }
|
|
629
|
+
: {}),
|
|
630
|
+
...(context.operationId ? { operationId: context.operationId } : {}),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
export function recordFetchError(context, error, status) {
|
|
635
|
+
const duration = performance.now() - context.startTime;
|
|
636
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
637
|
+
const event = {
|
|
638
|
+
v: 1,
|
|
639
|
+
type: 'error',
|
|
640
|
+
requestId: context.requestId,
|
|
641
|
+
url: context.url,
|
|
642
|
+
error: err.message,
|
|
643
|
+
duration,
|
|
644
|
+
...(context.contextRequestId
|
|
645
|
+
? { contextRequestId: context.contextRequestId }
|
|
646
|
+
: {}),
|
|
647
|
+
...(context.operationId ? { operationId: context.operationId } : {}),
|
|
648
|
+
};
|
|
649
|
+
const code = isSystemError(err) ? err.code : undefined;
|
|
650
|
+
if (code !== undefined) {
|
|
651
|
+
event.code = code;
|
|
652
|
+
}
|
|
653
|
+
if (status !== undefined) {
|
|
654
|
+
event.status = status;
|
|
655
|
+
}
|
|
656
|
+
publishFetchEvent(event);
|
|
657
|
+
const log = status === 429 ? logWarn : logError;
|
|
658
|
+
log('HTTP Request Error', {
|
|
659
|
+
requestId: context.requestId,
|
|
660
|
+
url: context.url,
|
|
661
|
+
status,
|
|
662
|
+
code,
|
|
663
|
+
error: err.message,
|
|
664
|
+
...(context.contextRequestId
|
|
665
|
+
? { contextRequestId: context.contextRequestId }
|
|
666
|
+
: {}),
|
|
667
|
+
...(context.operationId ? { operationId: context.operationId } : {}),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
671
|
+
function isRedirectStatus(status) {
|
|
672
|
+
return REDIRECT_STATUSES.has(status);
|
|
673
|
+
}
|
|
674
|
+
function cancelResponseBody(response) {
|
|
675
|
+
const cancelPromise = response.body?.cancel();
|
|
676
|
+
if (cancelPromise) {
|
|
677
|
+
cancelPromise.catch(() => {
|
|
678
|
+
// Best-effort cancellation; ignore failures.
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async function performFetchCycle(currentUrl, init, redirectLimit, redirectCount) {
|
|
683
|
+
const response = await fetch(currentUrl, { ...init, redirect: 'manual' });
|
|
684
|
+
if (!isRedirectStatus(response.status)) {
|
|
685
|
+
return { response };
|
|
686
|
+
}
|
|
687
|
+
assertRedirectWithinLimit(response, currentUrl, redirectLimit, redirectCount);
|
|
688
|
+
const location = getRedirectLocation(response, currentUrl);
|
|
689
|
+
cancelResponseBody(response);
|
|
690
|
+
return {
|
|
691
|
+
response,
|
|
692
|
+
nextUrl: resolveRedirectTarget(currentUrl, location),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function assertRedirectWithinLimit(response, currentUrl, redirectLimit, redirectCount) {
|
|
696
|
+
if (redirectCount < redirectLimit)
|
|
697
|
+
return;
|
|
698
|
+
cancelResponseBody(response);
|
|
699
|
+
throw new FetchError('Too many redirects', currentUrl);
|
|
700
|
+
}
|
|
701
|
+
function getRedirectLocation(response, currentUrl) {
|
|
702
|
+
const location = response.headers.get('location');
|
|
703
|
+
if (location)
|
|
704
|
+
return location;
|
|
705
|
+
cancelResponseBody(response);
|
|
706
|
+
throw new FetchError('Redirect response missing Location header', currentUrl);
|
|
707
|
+
}
|
|
708
|
+
function annotateRedirectError(error, url) {
|
|
709
|
+
if (!isRecord(error))
|
|
710
|
+
return;
|
|
711
|
+
error.requestUrl = url;
|
|
712
|
+
}
|
|
713
|
+
function resolveRedirectTarget(baseUrl, location) {
|
|
714
|
+
if (!URL.canParse(location, baseUrl)) {
|
|
715
|
+
throw createErrorWithCode('Invalid redirect target', 'EBADREDIRECT');
|
|
716
|
+
}
|
|
717
|
+
const resolved = new URL(location, baseUrl);
|
|
718
|
+
if (resolved.username || resolved.password) {
|
|
719
|
+
throw createErrorWithCode('Redirect target includes credentials', 'EBADREDIRECT');
|
|
720
|
+
}
|
|
721
|
+
return validateAndNormalizeUrl(resolved.href);
|
|
722
|
+
}
|
|
723
|
+
export async function fetchWithRedirects(url, init, maxRedirects) {
|
|
724
|
+
let currentUrl = url;
|
|
725
|
+
const redirectLimit = Math.max(0, maxRedirects);
|
|
726
|
+
for (let redirectCount = 0; redirectCount <= redirectLimit; redirectCount += 1) {
|
|
727
|
+
const { response, nextUrl } = await performFetchCycleSafely(currentUrl, init, redirectLimit, redirectCount);
|
|
728
|
+
if (!nextUrl) {
|
|
729
|
+
return { response, url: currentUrl };
|
|
730
|
+
}
|
|
731
|
+
currentUrl = nextUrl;
|
|
732
|
+
}
|
|
733
|
+
throw new FetchError('Too many redirects', currentUrl);
|
|
734
|
+
}
|
|
735
|
+
async function performFetchCycleSafely(currentUrl, init, redirectLimit, redirectCount) {
|
|
736
|
+
try {
|
|
737
|
+
return await performFetchCycle(currentUrl, init, redirectLimit, redirectCount);
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
annotateRedirectError(error, currentUrl);
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function assertContentLengthWithinLimit(response, url, maxBytes) {
|
|
745
|
+
const contentLengthHeader = response.headers.get('content-length');
|
|
746
|
+
if (!contentLengthHeader)
|
|
747
|
+
return;
|
|
748
|
+
const contentLength = Number.parseInt(contentLengthHeader, 10);
|
|
749
|
+
if (Number.isNaN(contentLength) || contentLength <= maxBytes) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
cancelResponseBody(response);
|
|
753
|
+
throw new FetchError(`Response exceeds maximum size of ${maxBytes} bytes`, url);
|
|
754
|
+
}
|
|
755
|
+
function createReadState() {
|
|
756
|
+
return {
|
|
757
|
+
decoder: new TextDecoder(),
|
|
758
|
+
parts: [],
|
|
759
|
+
total: 0,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
function appendChunk(state, chunk, maxBytes, url) {
|
|
763
|
+
state.total += chunk.byteLength;
|
|
764
|
+
if (state.total > maxBytes) {
|
|
765
|
+
throw new FetchError(`Response exceeds maximum size of ${maxBytes} bytes`, url);
|
|
766
|
+
}
|
|
767
|
+
const decoded = state.decoder.decode(chunk, { stream: true });
|
|
768
|
+
if (decoded)
|
|
769
|
+
state.parts.push(decoded);
|
|
770
|
+
}
|
|
771
|
+
function finalizeRead(state) {
|
|
772
|
+
const decoded = state.decoder.decode();
|
|
773
|
+
if (decoded)
|
|
774
|
+
state.parts.push(decoded);
|
|
775
|
+
}
|
|
776
|
+
function createAbortError(url) {
|
|
777
|
+
return new FetchError('Request was aborted during response read', url, 499, {
|
|
778
|
+
reason: 'aborted',
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async function cancelReaderQuietly(reader) {
|
|
782
|
+
try {
|
|
783
|
+
await reader.cancel();
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
// Ignore cancel errors; we're already failing this read.
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async function throwIfAborted(signal, url, reader) {
|
|
790
|
+
if (!signal?.aborted)
|
|
791
|
+
return;
|
|
792
|
+
await cancelReaderQuietly(reader);
|
|
793
|
+
throw createAbortError(url);
|
|
794
|
+
}
|
|
795
|
+
async function handleReadFailure(error, signal, url, reader) {
|
|
796
|
+
const aborted = signal?.aborted ?? false;
|
|
797
|
+
await cancelReaderQuietly(reader);
|
|
798
|
+
if (aborted) {
|
|
799
|
+
throw createAbortError(url);
|
|
800
|
+
}
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
async function readAllChunks(reader, state, url, maxBytes, signal) {
|
|
804
|
+
await throwIfAborted(signal, url, reader);
|
|
805
|
+
let result = await reader.read();
|
|
806
|
+
while (!result.done) {
|
|
807
|
+
appendChunk(state, result.value, maxBytes, url);
|
|
808
|
+
await throwIfAborted(signal, url, reader);
|
|
809
|
+
result = await reader.read();
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function readStreamWithLimit(stream, url, maxBytes, signal) {
|
|
813
|
+
const state = createReadState();
|
|
814
|
+
const reader = stream.getReader();
|
|
815
|
+
try {
|
|
816
|
+
await readAllChunks(reader, state, url, maxBytes, signal);
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
await handleReadFailure(error, signal, url, reader);
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
reader.releaseLock();
|
|
823
|
+
}
|
|
824
|
+
finalizeRead(state);
|
|
825
|
+
return { text: state.parts.join(''), size: state.total };
|
|
826
|
+
}
|
|
827
|
+
export async function readResponseText(response, url, maxBytes, signal) {
|
|
828
|
+
assertContentLengthWithinLimit(response, url, maxBytes);
|
|
829
|
+
if (!response.body) {
|
|
830
|
+
const text = await response.text();
|
|
831
|
+
const size = Buffer.byteLength(text);
|
|
832
|
+
if (size > maxBytes) {
|
|
833
|
+
throw new FetchError(`Response exceeds maximum size of ${maxBytes} bytes`, url);
|
|
834
|
+
}
|
|
835
|
+
return { text, size };
|
|
836
|
+
}
|
|
837
|
+
return readStreamWithLimit(response.body, url, maxBytes, signal);
|
|
838
|
+
}
|
|
839
|
+
const DEFAULT_HEADERS = {
|
|
840
|
+
'User-Agent': config.fetcher.userAgent,
|
|
841
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
842
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
843
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
844
|
+
Connection: 'keep-alive',
|
|
845
|
+
};
|
|
846
|
+
function buildHeaders() {
|
|
847
|
+
return { ...DEFAULT_HEADERS };
|
|
848
|
+
}
|
|
849
|
+
function buildRequestSignal(timeoutMs, external) {
|
|
850
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
851
|
+
if (!external)
|
|
852
|
+
return timeoutSignal;
|
|
853
|
+
return AbortSignal.any([external, timeoutSignal]);
|
|
854
|
+
}
|
|
855
|
+
function buildRequestInit(headers, signal) {
|
|
856
|
+
return {
|
|
857
|
+
method: 'GET',
|
|
858
|
+
headers,
|
|
859
|
+
signal,
|
|
860
|
+
dispatcher,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
function resolveResponseError(response, finalUrl) {
|
|
864
|
+
return (resolveRateLimitError(response, finalUrl) ??
|
|
865
|
+
resolveHttpError(response, finalUrl));
|
|
866
|
+
}
|
|
867
|
+
function resolveRateLimitError(response, finalUrl) {
|
|
868
|
+
return response.status === 429
|
|
869
|
+
? createRateLimitError(finalUrl, response.headers.get('retry-after'))
|
|
870
|
+
: null;
|
|
871
|
+
}
|
|
872
|
+
function resolveHttpError(response, finalUrl) {
|
|
873
|
+
return response.ok
|
|
874
|
+
? null
|
|
875
|
+
: createHttpError(finalUrl, response.status, response.statusText);
|
|
876
|
+
}
|
|
877
|
+
async function handleFetchResponse(response, finalUrl, telemetry, signal) {
|
|
878
|
+
const responseError = resolveResponseError(response, finalUrl);
|
|
879
|
+
if (responseError) {
|
|
880
|
+
cancelResponseBody(response);
|
|
881
|
+
throw responseError;
|
|
882
|
+
}
|
|
883
|
+
const { text, size } = await readResponseText(response, finalUrl, config.fetcher.maxContentLength, signal);
|
|
884
|
+
recordFetchResponse(telemetry, response, size);
|
|
885
|
+
return text;
|
|
886
|
+
}
|
|
887
|
+
async function fetchWithTelemetry(normalizedUrl, requestInit, timeoutMs) {
|
|
888
|
+
const telemetry = startFetchTelemetry(normalizedUrl, 'GET');
|
|
889
|
+
try {
|
|
890
|
+
return await fetchAndHandle(normalizedUrl, requestInit, telemetry);
|
|
891
|
+
}
|
|
892
|
+
catch (error) {
|
|
893
|
+
const mapped = mapFetchError(error, normalizedUrl, timeoutMs);
|
|
894
|
+
telemetry.url = mapped.url;
|
|
895
|
+
recordFetchError(telemetry, mapped, mapped.statusCode);
|
|
896
|
+
throw mapped;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async function fetchAndHandle(normalizedUrl, requestInit, telemetry) {
|
|
900
|
+
const { response, url: finalUrl } = await fetchWithRedirects(normalizedUrl, requestInit, config.fetcher.maxRedirects);
|
|
901
|
+
telemetry.url = finalUrl;
|
|
902
|
+
return handleFetchResponse(response, finalUrl, telemetry, requestInit.signal ?? undefined);
|
|
903
|
+
}
|
|
904
|
+
export async function fetchNormalizedUrl(normalizedUrl, options) {
|
|
905
|
+
const timeoutMs = config.fetcher.timeout;
|
|
906
|
+
const headers = buildHeaders();
|
|
907
|
+
const signal = buildRequestSignal(timeoutMs, options?.signal);
|
|
908
|
+
const requestInit = buildRequestInit(headers, signal);
|
|
909
|
+
return fetchWithTelemetry(normalizedUrl, requestInit, timeoutMs);
|
|
910
|
+
}
|