@pi-unipi/background-tasks 2.16.1 → 2.18.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 +21 -27
- package/package.json +3 -4
- package/src/cards.ts +76 -0
- package/src/child-process.ts +1 -1
- package/src/config.ts +0 -42
- package/src/context-visible-conversation-v2.ts +1 -1
- package/src/delegate/artifacts.ts +1 -1
- package/src/delegate/launch.ts +17 -30
- package/src/delegate/result-package.ts +1 -1
- package/src/delegate/runner.ts +1 -20
- package/src/delegate/seed.ts +1 -1
- package/src/delegate-extension.ts +16 -168
- package/src/index.ts +53 -25
- package/src/json-utils.ts +56 -0
- package/src/package-assets.ts +51 -0
- package/src/registry.ts +8 -459
- package/src/task-manager.ts +13 -2
- package/src/tools.ts +4 -189
- package/src/types.ts +17 -70
- package/extensions/anthropic-attribution.ts +0 -1
- package/extensions/fusion-child.ts +0 -1
- package/src/anthropic-attribution-path.ts +0 -21
- package/src/anthropic-attribution.ts +0 -1983
- package/src/attested-pi-run.ts +0 -612
- package/src/fixtures/fusion-golden-bytes.json +0 -310
- package/src/fixtures/fusion-validate-golden-bytes.json +0 -282
- package/src/fusion/artifacts.ts +0 -967
- package/src/fusion/budget.ts +0 -1162
- package/src/fusion/child-protocol.ts +0 -305
- package/src/fusion/claude-cache.ts +0 -207
- package/src/fusion/clean-context.ts +0 -91
- package/src/fusion/config.ts +0 -449
- package/src/fusion/context.ts +0 -265
- package/src/fusion/evaluation.ts +0 -800
- package/src/fusion/orchestrator.ts +0 -1288
- package/src/fusion/output-contract.ts +0 -34
- package/src/fusion/pi-child.ts +0 -2373
- package/src/fusion/prompts.ts +0 -345
- package/src/fusion/result-package.ts +0 -959
- package/src/fusion/source-policy.ts +0 -257
- package/src/fusion/types.ts +0 -1139
- package/src/fusion/web-fetch.ts +0 -1060
- package/src/fusion/workflows.ts +0 -184
- package/src/fusion-child-extension.ts +0 -1052
- package/src/fusion-extension.ts +0 -1293
- package/src/ui/fusion-model-selector.ts +0 -322
package/src/fusion/web-fetch.ts
DELETED
|
@@ -1,1060 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { lookup as nodeLookup } from 'node:dns/promises';
|
|
3
|
-
import * as http from 'node:http';
|
|
4
|
-
import * as https from 'node:https';
|
|
5
|
-
import { isIP } from 'node:net';
|
|
6
|
-
import { performance } from 'node:perf_hooks';
|
|
7
|
-
import { TextDecoder } from 'node:util';
|
|
8
|
-
|
|
9
|
-
import type TurndownService from 'turndown';
|
|
10
|
-
|
|
11
|
-
export const FUSION_WEB_FETCH_TIMEOUT_MS = 90_000;
|
|
12
|
-
export const FUSION_WEB_FETCH_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
13
|
-
export const FUSION_WEB_FETCH_MAX_OUTPUT_BYTES = 32 * 1024;
|
|
14
|
-
export const FUSION_WEB_FETCH_MAX_REDIRECTS = 5;
|
|
15
|
-
|
|
16
|
-
export interface FusionWebFetchRequest {
|
|
17
|
-
url: string;
|
|
18
|
-
extract?: 'text' | 'markdown';
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface FusionWebFetchResult {
|
|
22
|
-
url: string;
|
|
23
|
-
final_url: string;
|
|
24
|
-
status: number;
|
|
25
|
-
content_type: string;
|
|
26
|
-
format: 'text' | 'markdown';
|
|
27
|
-
truncated: boolean;
|
|
28
|
-
content: string;
|
|
29
|
-
response_bytes: number;
|
|
30
|
-
content_sha256: string;
|
|
31
|
-
duration_ms: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type FusionWebFetchErrorCode =
|
|
35
|
-
| 'invalid_url'
|
|
36
|
-
| 'unsupported_scheme'
|
|
37
|
-
| 'blocked_address'
|
|
38
|
-
| 'dns_failure'
|
|
39
|
-
| 'redirect_limit'
|
|
40
|
-
| 'redirect_blocked'
|
|
41
|
-
| 'response_too_large'
|
|
42
|
-
| 'unsupported_content_type'
|
|
43
|
-
| 'request_timeout'
|
|
44
|
-
| 'network_error'
|
|
45
|
-
| 'extraction_failed'
|
|
46
|
-
| 'http_error';
|
|
47
|
-
|
|
48
|
-
export interface FusionDnsAddress {
|
|
49
|
-
address: string;
|
|
50
|
-
family: 4 | 6;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export type FusionDnsLookup = (hostname: string) => Promise<readonly FusionDnsAddress[]>;
|
|
54
|
-
|
|
55
|
-
export type FusionTransportRequest = (
|
|
56
|
-
protocol: 'http:' | 'https:',
|
|
57
|
-
options: http.RequestOptions | https.RequestOptions,
|
|
58
|
-
) => http.ClientRequest;
|
|
59
|
-
|
|
60
|
-
export type FusionContentExtractor = (
|
|
61
|
-
body: Buffer,
|
|
62
|
-
contentType: string,
|
|
63
|
-
requestedFormat: 'text' | 'markdown',
|
|
64
|
-
) => Promise<{ content: string; format: 'text' | 'markdown' }>;
|
|
65
|
-
|
|
66
|
-
export interface FusionWebFetchOptions {
|
|
67
|
-
lookup?: FusionDnsLookup;
|
|
68
|
-
request?: FusionTransportRequest;
|
|
69
|
-
agent?: http.Agent | https.Agent | false;
|
|
70
|
-
createConnection?: http.RequestOptions['createConnection'];
|
|
71
|
-
now?: () => number;
|
|
72
|
-
timeoutMs?: number;
|
|
73
|
-
maxResponseBytes?: number;
|
|
74
|
-
maxOutputBytes?: number;
|
|
75
|
-
maxRedirects?: number;
|
|
76
|
-
allowBlockedAddressesForTests?: boolean;
|
|
77
|
-
/** Test seam for proving extraction remains inside the full-operation deadline. */
|
|
78
|
-
extractContent?: FusionContentExtractor;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface NormalizedRequestUrl {
|
|
82
|
-
url: URL;
|
|
83
|
-
hostname: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
interface VettedHost {
|
|
87
|
-
selectedAddress: FusionDnsAddress;
|
|
88
|
-
resolvedAddresses: readonly FusionDnsAddress[];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
interface FetchOneSuccess {
|
|
92
|
-
kind: 'success';
|
|
93
|
-
status: number;
|
|
94
|
-
contentType: string;
|
|
95
|
-
body: Buffer;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
interface FetchOneRedirect {
|
|
99
|
-
kind: 'redirect';
|
|
100
|
-
status: number;
|
|
101
|
-
location: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
type FetchOneResult = FetchOneSuccess | FetchOneRedirect;
|
|
105
|
-
|
|
106
|
-
interface EffectiveOptions {
|
|
107
|
-
lookup: FusionDnsLookup;
|
|
108
|
-
request: FusionTransportRequest;
|
|
109
|
-
agent: http.Agent | https.Agent | false;
|
|
110
|
-
createConnection?: http.RequestOptions['createConnection'];
|
|
111
|
-
now: () => number;
|
|
112
|
-
timeoutMs: number;
|
|
113
|
-
maxResponseBytes: number;
|
|
114
|
-
maxOutputBytes: number;
|
|
115
|
-
maxRedirects: number;
|
|
116
|
-
allowBlockedAddressesForTests: boolean;
|
|
117
|
-
extractContent: FusionContentExtractor;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
interface ExtractionResult {
|
|
121
|
-
content: string;
|
|
122
|
-
format: 'text' | 'markdown';
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
interface TableReplacement {
|
|
126
|
-
token: string;
|
|
127
|
-
markdown: string;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
type TurndownServiceConstructor = typeof TurndownService;
|
|
131
|
-
|
|
132
|
-
let turndownServiceLoad: Promise<TurndownServiceConstructor> | undefined;
|
|
133
|
-
|
|
134
|
-
const USER_AGENT = 'pi-background-tasks fusion_web_fetch/1.0';
|
|
135
|
-
const ACCEPT_HEADER = 'text/markdown, text/html;q=0.9, application/xhtml+xml;q=0.8, text/plain;q=0.7';
|
|
136
|
-
const METADATA_HOSTNAMES = new Set([
|
|
137
|
-
'metadata',
|
|
138
|
-
'metadata.local',
|
|
139
|
-
'metadata.google.internal',
|
|
140
|
-
'metadata.goog',
|
|
141
|
-
'instance-data',
|
|
142
|
-
'instance-data.ec2.internal',
|
|
143
|
-
]);
|
|
144
|
-
|
|
145
|
-
const IPV4_DENY_RANGES: readonly [number, number][] = [
|
|
146
|
-
[ipv4ToNumberLiteral('0.0.0.0'), 8],
|
|
147
|
-
[ipv4ToNumberLiteral('10.0.0.0'), 8],
|
|
148
|
-
[ipv4ToNumberLiteral('100.64.0.0'), 10],
|
|
149
|
-
[ipv4ToNumberLiteral('127.0.0.0'), 8],
|
|
150
|
-
[ipv4ToNumberLiteral('169.254.0.0'), 16],
|
|
151
|
-
[ipv4ToNumberLiteral('172.16.0.0'), 12],
|
|
152
|
-
[ipv4ToNumberLiteral('192.0.0.0'), 24],
|
|
153
|
-
[ipv4ToNumberLiteral('192.0.2.0'), 24],
|
|
154
|
-
[ipv4ToNumberLiteral('192.168.0.0'), 16],
|
|
155
|
-
[ipv4ToNumberLiteral('198.18.0.0'), 15],
|
|
156
|
-
[ipv4ToNumberLiteral('198.51.100.0'), 24],
|
|
157
|
-
[ipv4ToNumberLiteral('203.0.113.0'), 24],
|
|
158
|
-
[ipv4ToNumberLiteral('224.0.0.0'), 4],
|
|
159
|
-
[ipv4ToNumberLiteral('240.0.0.0'), 4],
|
|
160
|
-
[ipv4ToNumberLiteral('255.255.255.255'), 32],
|
|
161
|
-
[ipv4ToNumberLiteral('169.254.169.254'), 32],
|
|
162
|
-
] as const;
|
|
163
|
-
|
|
164
|
-
const IPV6_DENY_RANGES: readonly [bigint, number][] = [
|
|
165
|
-
[ipv6ToBigIntLiteral('::'), 128],
|
|
166
|
-
[ipv6ToBigIntLiteral('::1'), 128],
|
|
167
|
-
[ipv6ToBigIntLiteral('64:ff9b::'), 96],
|
|
168
|
-
[ipv6ToBigIntLiteral('64:ff9b:1::'), 48],
|
|
169
|
-
[ipv6ToBigIntLiteral('100::'), 64],
|
|
170
|
-
[ipv6ToBigIntLiteral('2001:2::'), 48],
|
|
171
|
-
[ipv6ToBigIntLiteral('2001:db8::'), 32],
|
|
172
|
-
[ipv6ToBigIntLiteral('2002::'), 16],
|
|
173
|
-
[ipv6ToBigIntLiteral('fc00::'), 7],
|
|
174
|
-
[ipv6ToBigIntLiteral('fe80::'), 10],
|
|
175
|
-
[ipv6ToBigIntLiteral('ff00::'), 8],
|
|
176
|
-
[ipv6ToBigIntLiteral('fd00:ec2::254'), 128],
|
|
177
|
-
[ipv6ToBigIntLiteral('::ffff:0:0'), 96],
|
|
178
|
-
] as const;
|
|
179
|
-
|
|
180
|
-
export class FusionWebFetchError extends Error {
|
|
181
|
-
public readonly code: FusionWebFetchErrorCode;
|
|
182
|
-
public readonly url?: string;
|
|
183
|
-
public readonly status?: number;
|
|
184
|
-
|
|
185
|
-
public constructor(
|
|
186
|
-
code: FusionWebFetchErrorCode,
|
|
187
|
-
message: string,
|
|
188
|
-
details: { url?: string; status?: number; cause?: Error } = {},
|
|
189
|
-
) {
|
|
190
|
-
super(message, details.cause === undefined ? undefined : { cause: details.cause });
|
|
191
|
-
this.name = 'FusionWebFetchError';
|
|
192
|
-
this.code = code;
|
|
193
|
-
if (details.url !== undefined) this.url = details.url;
|
|
194
|
-
if (details.status !== undefined) this.status = details.status;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export async function fusionWebFetch(
|
|
199
|
-
req: FusionWebFetchRequest,
|
|
200
|
-
options: FusionWebFetchOptions = {},
|
|
201
|
-
): Promise<FusionWebFetchResult> {
|
|
202
|
-
const effective = mergeOptions(options);
|
|
203
|
-
const requestedFormat = req.extract ?? 'markdown';
|
|
204
|
-
const start = effective.now();
|
|
205
|
-
const deadlineMs = start + effective.timeoutMs;
|
|
206
|
-
const requestedUrl = normalizeRequestUrl(req.url).url;
|
|
207
|
-
let currentUrl = requestedUrl;
|
|
208
|
-
|
|
209
|
-
for (let redirectCount = 0; ; redirectCount += 1) {
|
|
210
|
-
const remainingMs = deadlineMs - effective.now();
|
|
211
|
-
if (remainingMs <= 0) {
|
|
212
|
-
throw new FusionWebFetchError('request_timeout', 'fusion_web_fetch request timeout elapsed', {
|
|
213
|
-
url: currentUrl.toString(),
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const result = await fetchOne(currentUrl, effective, deadlineMs, redirectCount > 0);
|
|
218
|
-
if (result.kind === 'redirect') {
|
|
219
|
-
if (redirectCount >= effective.maxRedirects) {
|
|
220
|
-
throw new FusionWebFetchError(
|
|
221
|
-
'redirect_limit',
|
|
222
|
-
`fusion_web_fetch redirect limit exceeded (${String(effective.maxRedirects)})`,
|
|
223
|
-
{ url: currentUrl.toString(), status: result.status },
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
currentUrl = normalizeRedirectUrl(currentUrl, result.location);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const extracted = await extractWithinDeadline(
|
|
231
|
-
result.body,
|
|
232
|
-
result.contentType,
|
|
233
|
-
requestedFormat,
|
|
234
|
-
effective,
|
|
235
|
-
deadlineMs,
|
|
236
|
-
currentUrl,
|
|
237
|
-
);
|
|
238
|
-
const capped = capUtf8Bytes(extracted.content, effective.maxOutputBytes);
|
|
239
|
-
const contentSha256 = createHash('sha256').update(result.body).digest('hex');
|
|
240
|
-
assertFetchDeadline(effective, deadlineMs, currentUrl, 'content extraction');
|
|
241
|
-
const durationMs = Math.max(0, effective.now() - start);
|
|
242
|
-
if (durationMs > effective.timeoutMs) {
|
|
243
|
-
throw fetchTimeout(currentUrl, 'content extraction');
|
|
244
|
-
}
|
|
245
|
-
return {
|
|
246
|
-
url: requestedUrl.toString(),
|
|
247
|
-
final_url: currentUrl.toString(),
|
|
248
|
-
status: result.status,
|
|
249
|
-
content_type: result.contentType,
|
|
250
|
-
format: extracted.format,
|
|
251
|
-
truncated: capped.truncated,
|
|
252
|
-
content: capped.content,
|
|
253
|
-
response_bytes: result.body.byteLength,
|
|
254
|
-
content_sha256: contentSha256,
|
|
255
|
-
duration_ms: durationMs,
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function mergeOptions(options: FusionWebFetchOptions): EffectiveOptions {
|
|
261
|
-
return {
|
|
262
|
-
lookup: options.lookup ?? defaultLookup,
|
|
263
|
-
request: options.request ?? defaultTransportRequest,
|
|
264
|
-
agent: options.agent ?? false,
|
|
265
|
-
createConnection: options.createConnection,
|
|
266
|
-
now: options.now ?? (() => performance.now()),
|
|
267
|
-
timeoutMs: options.timeoutMs ?? FUSION_WEB_FETCH_TIMEOUT_MS,
|
|
268
|
-
maxResponseBytes: options.maxResponseBytes ?? FUSION_WEB_FETCH_MAX_RESPONSE_BYTES,
|
|
269
|
-
maxOutputBytes: options.maxOutputBytes ?? FUSION_WEB_FETCH_MAX_OUTPUT_BYTES,
|
|
270
|
-
maxRedirects: options.maxRedirects ?? FUSION_WEB_FETCH_MAX_REDIRECTS,
|
|
271
|
-
allowBlockedAddressesForTests: options.allowBlockedAddressesForTests ?? false,
|
|
272
|
-
extractContent: options.extractContent ?? extractContent,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function fetchTimeout(url: URL, phase: string): FusionWebFetchError {
|
|
277
|
-
return new FusionWebFetchError(
|
|
278
|
-
'request_timeout',
|
|
279
|
-
`fusion_web_fetch request timeout elapsed during ${phase}`,
|
|
280
|
-
{ url: url.toString() },
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function assertFetchDeadline(
|
|
285
|
-
options: EffectiveOptions,
|
|
286
|
-
deadlineMs: number,
|
|
287
|
-
url: URL,
|
|
288
|
-
phase: string,
|
|
289
|
-
): number {
|
|
290
|
-
const remainingMs = deadlineMs - options.now();
|
|
291
|
-
if (remainingMs <= 0) throw fetchTimeout(url, phase);
|
|
292
|
-
return remainingMs;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async function extractWithinDeadline(
|
|
296
|
-
body: Buffer,
|
|
297
|
-
contentType: string,
|
|
298
|
-
requestedFormat: 'text' | 'markdown',
|
|
299
|
-
options: EffectiveOptions,
|
|
300
|
-
deadlineMs: number,
|
|
301
|
-
url: URL,
|
|
302
|
-
): Promise<ExtractionResult> {
|
|
303
|
-
const remainingMs = assertFetchDeadline(options, deadlineMs, url, 'content extraction');
|
|
304
|
-
let timer: NodeJS.Timeout | undefined;
|
|
305
|
-
try {
|
|
306
|
-
const timeout = new Promise<never>((_resolve, reject) => {
|
|
307
|
-
timer = setTimeout(() => reject(fetchTimeout(url, 'content extraction')), remainingMs);
|
|
308
|
-
});
|
|
309
|
-
const extraction = Promise.resolve().then(() =>
|
|
310
|
-
options.extractContent(body, contentType, requestedFormat),
|
|
311
|
-
);
|
|
312
|
-
const result = await Promise.race([extraction, timeout]);
|
|
313
|
-
assertFetchDeadline(options, deadlineMs, url, 'content extraction');
|
|
314
|
-
return result;
|
|
315
|
-
} finally {
|
|
316
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function normalizeRequestUrl(rawUrl: string): NormalizedRequestUrl {
|
|
321
|
-
let parsed: URL;
|
|
322
|
-
try {
|
|
323
|
-
parsed = new URL(rawUrl.trim());
|
|
324
|
-
} catch (error) {
|
|
325
|
-
throw new FusionWebFetchError(
|
|
326
|
-
'invalid_url',
|
|
327
|
-
'fusion_web_fetch requires a valid absolute URL',
|
|
328
|
-
error instanceof Error ? { cause: error } : {},
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
333
|
-
throw new FusionWebFetchError(
|
|
334
|
-
'unsupported_scheme',
|
|
335
|
-
'fusion_web_fetch supports only absolute http: and https: URLs',
|
|
336
|
-
{ url: rawUrl },
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
if (parsed.username !== '' || parsed.password !== '') {
|
|
340
|
-
throw new FusionWebFetchError('invalid_url', 'fusion_web_fetch URL credentials are blocked', {
|
|
341
|
-
url: rawUrl,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
if (parsed.hostname.length === 0) {
|
|
345
|
-
throw new FusionWebFetchError('invalid_url', 'fusion_web_fetch URL host is required', {
|
|
346
|
-
url: rawUrl,
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
parsed.hash = '';
|
|
351
|
-
return { url: parsed, hostname: normalizeHostname(parsed.hostname) };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function normalizeRedirectUrl(baseUrl: URL, location: string): URL {
|
|
355
|
-
let next: URL;
|
|
356
|
-
try {
|
|
357
|
-
next = new URL(location, baseUrl);
|
|
358
|
-
} catch (error) {
|
|
359
|
-
throw new FusionWebFetchError(
|
|
360
|
-
'redirect_blocked',
|
|
361
|
-
'fusion_web_fetch redirect target is not a valid URL',
|
|
362
|
-
error instanceof Error ? { url: baseUrl.toString(), cause: error } : { url: baseUrl.toString() },
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
try {
|
|
367
|
-
return normalizeRequestUrl(next.toString()).url;
|
|
368
|
-
} catch (error) {
|
|
369
|
-
if (error instanceof FusionWebFetchError) {
|
|
370
|
-
throw new FusionWebFetchError('redirect_blocked', `fusion_web_fetch redirect blocked: ${error.message}`, {
|
|
371
|
-
url: next.toString(),
|
|
372
|
-
cause: error,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
throw error;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async function fetchOne(
|
|
380
|
-
url: URL,
|
|
381
|
-
options: EffectiveOptions,
|
|
382
|
-
deadlineMs: number,
|
|
383
|
-
redirectHop: boolean,
|
|
384
|
-
): Promise<FetchOneResult> {
|
|
385
|
-
const normalized = normalizeRequestUrl(url.toString());
|
|
386
|
-
const vetted = await vetHost(
|
|
387
|
-
normalized.hostname,
|
|
388
|
-
options,
|
|
389
|
-
redirectHop,
|
|
390
|
-
normalized.url.toString(),
|
|
391
|
-
deadlineMs,
|
|
392
|
-
);
|
|
393
|
-
const remainingMs = deadlineMs - options.now();
|
|
394
|
-
if (remainingMs <= 0) {
|
|
395
|
-
throw new FusionWebFetchError('request_timeout', 'fusion_web_fetch request timeout elapsed', {
|
|
396
|
-
url: normalized.url.toString(),
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
return await executeRequest(normalized.url, vetted.selectedAddress, options, remainingMs);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async function vetHost(
|
|
403
|
-
hostname: string,
|
|
404
|
-
options: EffectiveOptions,
|
|
405
|
-
redirectHop: boolean,
|
|
406
|
-
url: string,
|
|
407
|
-
deadlineMs: number,
|
|
408
|
-
): Promise<VettedHost> {
|
|
409
|
-
if (METADATA_HOSTNAMES.has(hostname) || hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
410
|
-
throwAddressError(redirectHop, `fusion_web_fetch blocked host: ${hostname}`, url);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const literalFamily = isIP(hostname);
|
|
414
|
-
const literalAddress: FusionDnsAddress = { address: hostname, family: literalFamily === 4 ? 4 : 6 };
|
|
415
|
-
const resolvedAddresses =
|
|
416
|
-
literalFamily === 0
|
|
417
|
-
? await resolveWithLookup(hostname, options.lookup, url, deadlineMs - options.now())
|
|
418
|
-
: [literalAddress];
|
|
419
|
-
|
|
420
|
-
if (resolvedAddresses.length === 0) {
|
|
421
|
-
throw new FusionWebFetchError('dns_failure', `DNS lookup returned no addresses for ${hostname}`, { url });
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
for (const address of resolvedAddresses) {
|
|
425
|
-
const classification = classifyAddress(address.address);
|
|
426
|
-
if (!classification.public && !options.allowBlockedAddressesForTests) {
|
|
427
|
-
throwAddressError(
|
|
428
|
-
redirectHop,
|
|
429
|
-
`fusion_web_fetch blocked non-public address for ${hostname}: ${address.address}`,
|
|
430
|
-
url,
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const selectedAddress = resolvedAddresses[0];
|
|
436
|
-
if (selectedAddress === undefined) {
|
|
437
|
-
throw new FusionWebFetchError('dns_failure', `DNS lookup returned no addresses for ${hostname}`, { url });
|
|
438
|
-
}
|
|
439
|
-
return { selectedAddress, resolvedAddresses };
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function throwAddressError(redirectHop: boolean, message: string, url: string): never {
|
|
443
|
-
throw new FusionWebFetchError(redirectHop ? 'redirect_blocked' : 'blocked_address', message, { url });
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
async function resolveWithLookup(
|
|
447
|
-
hostname: string,
|
|
448
|
-
lookup: FusionDnsLookup,
|
|
449
|
-
url: string,
|
|
450
|
-
remainingMs: number,
|
|
451
|
-
): Promise<readonly FusionDnsAddress[]> {
|
|
452
|
-
if (remainingMs <= 0) {
|
|
453
|
-
throw new FusionWebFetchError('request_timeout', 'fusion_web_fetch request timeout elapsed', { url });
|
|
454
|
-
}
|
|
455
|
-
let timer: NodeJS.Timeout | undefined;
|
|
456
|
-
try {
|
|
457
|
-
return await Promise.race([
|
|
458
|
-
lookup(hostname),
|
|
459
|
-
new Promise<never>((_resolve, reject) => {
|
|
460
|
-
timer = setTimeout(
|
|
461
|
-
() =>
|
|
462
|
-
reject(
|
|
463
|
-
new FusionWebFetchError(
|
|
464
|
-
'request_timeout',
|
|
465
|
-
'fusion_web_fetch request timeout elapsed during DNS lookup',
|
|
466
|
-
{ url },
|
|
467
|
-
),
|
|
468
|
-
),
|
|
469
|
-
Math.max(1, remainingMs),
|
|
470
|
-
);
|
|
471
|
-
}),
|
|
472
|
-
]);
|
|
473
|
-
} catch (error) {
|
|
474
|
-
if (error instanceof FusionWebFetchError) throw error;
|
|
475
|
-
throw new FusionWebFetchError(
|
|
476
|
-
'dns_failure',
|
|
477
|
-
`DNS lookup failed for ${hostname}: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
478
|
-
error instanceof Error ? { url, cause: error } : { url },
|
|
479
|
-
);
|
|
480
|
-
} finally {
|
|
481
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function executeRequest(
|
|
486
|
-
url: URL,
|
|
487
|
-
selectedAddress: FusionDnsAddress,
|
|
488
|
-
options: EffectiveOptions,
|
|
489
|
-
remainingMs: number,
|
|
490
|
-
): Promise<FetchOneResult> {
|
|
491
|
-
return new Promise<FetchOneResult>((resolve, reject) => {
|
|
492
|
-
let settled = false;
|
|
493
|
-
let requestSocket: { destroy: () => void } | undefined;
|
|
494
|
-
let timer: NodeJS.Timeout | undefined;
|
|
495
|
-
|
|
496
|
-
const settleResolve = (result: FetchOneResult): void => {
|
|
497
|
-
if (settled) return;
|
|
498
|
-
settled = true;
|
|
499
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
500
|
-
resolve(result);
|
|
501
|
-
};
|
|
502
|
-
const settleReject = (error: FusionWebFetchError): void => {
|
|
503
|
-
if (settled) return;
|
|
504
|
-
settled = true;
|
|
505
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
506
|
-
reject(error);
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
const request = options.request(url.protocol as 'http:' | 'https:', buildRequestOptions(url, selectedAddress, options));
|
|
510
|
-
timer = setTimeout(() => {
|
|
511
|
-
settleReject(new FusionWebFetchError('request_timeout', 'fusion_web_fetch request timeout elapsed', {
|
|
512
|
-
url: url.toString(),
|
|
513
|
-
}));
|
|
514
|
-
request.destroy();
|
|
515
|
-
requestSocket?.destroy();
|
|
516
|
-
}, Math.max(1, remainingMs));
|
|
517
|
-
request.once('socket', (socket) => {
|
|
518
|
-
requestSocket = socket;
|
|
519
|
-
});
|
|
520
|
-
request.once('response', (response) => {
|
|
521
|
-
const remoteAddress = response.socket.remoteAddress;
|
|
522
|
-
if (!addressMatches(selectedAddress.address, remoteAddress)) {
|
|
523
|
-
response.destroy();
|
|
524
|
-
settleReject(
|
|
525
|
-
new FusionWebFetchError(
|
|
526
|
-
'blocked_address',
|
|
527
|
-
`fusion_web_fetch socket remote address mismatch: expected ${selectedAddress.address}, got ${remoteAddress ?? 'missing'}`,
|
|
528
|
-
errorDetails(url.toString(), response.statusCode),
|
|
529
|
-
),
|
|
530
|
-
);
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
const remoteClass = classifyAddress(remoteAddress ?? '');
|
|
534
|
-
if (!remoteClass.public && !options.allowBlockedAddressesForTests) {
|
|
535
|
-
response.destroy();
|
|
536
|
-
settleReject(
|
|
537
|
-
new FusionWebFetchError(
|
|
538
|
-
'blocked_address',
|
|
539
|
-
'fusion_web_fetch socket remote address is blocked',
|
|
540
|
-
errorDetails(url.toString(), response.statusCode),
|
|
541
|
-
),
|
|
542
|
-
);
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const status = response.statusCode ?? 0;
|
|
547
|
-
const location = firstHeader(response, 'location');
|
|
548
|
-
if (isRedirectStatus(status)) {
|
|
549
|
-
// Redirect payloads are never consumed. Destroy the response/socket before
|
|
550
|
-
// advancing so an endless or oversized redirect body cannot outlive this hop.
|
|
551
|
-
response.destroy();
|
|
552
|
-
if (location === undefined || location.trim().length === 0) {
|
|
553
|
-
settleReject(
|
|
554
|
-
new FusionWebFetchError('http_error', `fusion_web_fetch redirect status ${String(status)} lacks Location`, {
|
|
555
|
-
url: url.toString(),
|
|
556
|
-
status,
|
|
557
|
-
}),
|
|
558
|
-
);
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
settleResolve({ kind: 'redirect', status, location });
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
if (status < 200 || status > 299) {
|
|
566
|
-
response.destroy();
|
|
567
|
-
settleReject(
|
|
568
|
-
new FusionWebFetchError('http_error', `fusion_web_fetch HTTP status ${String(status)}`, {
|
|
569
|
-
url: url.toString(),
|
|
570
|
-
status,
|
|
571
|
-
}),
|
|
572
|
-
);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const contentType = firstHeader(response, 'content-type') ?? '';
|
|
577
|
-
const contentTypeError = unsupportedContentTypeError(contentType, url.toString(), status);
|
|
578
|
-
if (contentTypeError !== undefined) {
|
|
579
|
-
response.destroy();
|
|
580
|
-
settleReject(contentTypeError);
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
let contentLength: number | undefined;
|
|
584
|
-
try {
|
|
585
|
-
contentLength = readContentLength(firstHeader(response, 'content-length'), url.toString(), status);
|
|
586
|
-
} catch (error) {
|
|
587
|
-
response.destroy();
|
|
588
|
-
settleReject(
|
|
589
|
-
error instanceof FusionWebFetchError
|
|
590
|
-
? error
|
|
591
|
-
: new FusionWebFetchError('network_error', 'fusion_web_fetch invalid Content-Length check failed'),
|
|
592
|
-
);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (contentLength !== undefined && contentLength > options.maxResponseBytes) {
|
|
596
|
-
response.destroy();
|
|
597
|
-
settleReject(
|
|
598
|
-
new FusionWebFetchError('response_too_large', 'fusion_web_fetch Content-Length exceeds response cap', {
|
|
599
|
-
url: url.toString(),
|
|
600
|
-
status,
|
|
601
|
-
}),
|
|
602
|
-
);
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
let bytesRead = 0;
|
|
607
|
-
const chunks: Buffer[] = [];
|
|
608
|
-
response.on('data', (chunk: Buffer) => {
|
|
609
|
-
const nextBytesRead = bytesRead + chunk.byteLength;
|
|
610
|
-
if (nextBytesRead > options.maxResponseBytes) {
|
|
611
|
-
response.destroy();
|
|
612
|
-
settleReject(
|
|
613
|
-
new FusionWebFetchError('response_too_large', 'fusion_web_fetch streamed body exceeds response cap', {
|
|
614
|
-
url: url.toString(),
|
|
615
|
-
status,
|
|
616
|
-
}),
|
|
617
|
-
);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
bytesRead = nextBytesRead;
|
|
621
|
-
chunks.push(chunk);
|
|
622
|
-
});
|
|
623
|
-
response.once('end', () => {
|
|
624
|
-
settleResolve({ kind: 'success', status, contentType, body: Buffer.concat(chunks, bytesRead) });
|
|
625
|
-
});
|
|
626
|
-
response.once('error', (error) => {
|
|
627
|
-
settleReject(mapNetworkError(error, url.toString(), status));
|
|
628
|
-
});
|
|
629
|
-
});
|
|
630
|
-
request.once('error', (error) => {
|
|
631
|
-
settleReject(mapNetworkError(error, url.toString()));
|
|
632
|
-
});
|
|
633
|
-
request.end();
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function buildRequestOptions(
|
|
638
|
-
url: URL,
|
|
639
|
-
selectedAddress: FusionDnsAddress,
|
|
640
|
-
options: EffectiveOptions,
|
|
641
|
-
): http.RequestOptions | https.RequestOptions {
|
|
642
|
-
const requestOptions: https.RequestOptions = {
|
|
643
|
-
protocol: url.protocol,
|
|
644
|
-
hostname: url.hostname,
|
|
645
|
-
port: url.port,
|
|
646
|
-
family: selectedAddress.family,
|
|
647
|
-
method: 'GET',
|
|
648
|
-
path: `${url.pathname}${url.search}`,
|
|
649
|
-
agent: options.agent,
|
|
650
|
-
headers: {
|
|
651
|
-
Accept: ACCEPT_HEADER,
|
|
652
|
-
'Accept-Encoding': 'identity',
|
|
653
|
-
Host: url.host,
|
|
654
|
-
'User-Agent': USER_AGENT,
|
|
655
|
-
},
|
|
656
|
-
lookup: forceSelectedLookup(selectedAddress),
|
|
657
|
-
};
|
|
658
|
-
if (url.protocol === 'https:') requestOptions.servername = normalizeHostname(url.hostname);
|
|
659
|
-
if (options.createConnection !== undefined) requestOptions.createConnection = options.createConnection;
|
|
660
|
-
return requestOptions;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function forceSelectedLookup(selectedAddress: FusionDnsAddress): NonNullable<http.RequestOptions['lookup']> {
|
|
664
|
-
// Every DNS answer is vetted before this point. The request then receives a lookup hook
|
|
665
|
-
// that can return only the vetted address, and the response socket is checked against it.
|
|
666
|
-
// That pins the connection to the reviewed IP and closes the DNS rebinding gap from
|
|
667
|
-
// validate-then-call-global-fetch implementations.
|
|
668
|
-
return (_hostname, lookupOptions, callback) => {
|
|
669
|
-
if (typeof lookupOptions === 'object' && lookupOptions.all === true) {
|
|
670
|
-
const allCallback = callback as (error: NodeJS.ErrnoException | null, addresses: FusionDnsAddress[]) => void;
|
|
671
|
-
allCallback(null, [selectedAddress]);
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
callback(null, selectedAddress.address, selectedAddress.family);
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
function defaultTransportRequest(
|
|
679
|
-
protocol: 'http:' | 'https:',
|
|
680
|
-
options: http.RequestOptions | https.RequestOptions,
|
|
681
|
-
): http.ClientRequest {
|
|
682
|
-
return protocol === 'https:' ? https.request(options) : http.request(options);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
async function defaultLookup(hostname: string): Promise<readonly FusionDnsAddress[]> {
|
|
686
|
-
const records = await nodeLookup(hostname, { all: true, verbatim: true });
|
|
687
|
-
return records.map((record) => ({ address: record.address, family: normalizeFamily(record.family) }));
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
function normalizeFamily(family: number): 4 | 6 {
|
|
691
|
-
if (family === 4 || family === 6) return family;
|
|
692
|
-
throw new Error(`Unsupported DNS address family: ${String(family)}`);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function firstHeader(response: http.IncomingMessage, name: string): string | undefined {
|
|
696
|
-
const value = response.headers[name];
|
|
697
|
-
if (Array.isArray(value)) return value.join(', ');
|
|
698
|
-
return value;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function isRedirectStatus(status: number): boolean {
|
|
702
|
-
return status === 300 || status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function mapNetworkError(error: Error, url: string, status?: number): FusionWebFetchError {
|
|
706
|
-
if (error instanceof FusionWebFetchError) return error;
|
|
707
|
-
return new FusionWebFetchError(
|
|
708
|
-
'network_error',
|
|
709
|
-
`fusion_web_fetch network error: ${error.message}`,
|
|
710
|
-
errorDetails(url, status, error),
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function errorDetails(
|
|
715
|
-
url: string,
|
|
716
|
-
status?: number,
|
|
717
|
-
cause?: Error,
|
|
718
|
-
): { url: string; status?: number; cause?: Error } {
|
|
719
|
-
const details: { url: string; status?: number; cause?: Error } = { url };
|
|
720
|
-
if (status !== undefined) details.status = status;
|
|
721
|
-
if (cause !== undefined) details.cause = cause;
|
|
722
|
-
return details;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
function unsupportedContentTypeError(
|
|
726
|
-
contentType: string,
|
|
727
|
-
url: string,
|
|
728
|
-
status: number,
|
|
729
|
-
): FusionWebFetchError | undefined {
|
|
730
|
-
const mediaType = mediaTypeFromContentType(contentType);
|
|
731
|
-
if (
|
|
732
|
-
mediaType === 'text/html' ||
|
|
733
|
-
mediaType === 'application/xhtml+xml' ||
|
|
734
|
-
mediaType === 'text/plain' ||
|
|
735
|
-
mediaType === 'text/markdown'
|
|
736
|
-
) {
|
|
737
|
-
return undefined;
|
|
738
|
-
}
|
|
739
|
-
return new FusionWebFetchError(
|
|
740
|
-
'unsupported_content_type',
|
|
741
|
-
`fusion_web_fetch unsupported content type: ${contentType.length === 0 ? 'missing' : contentType}`,
|
|
742
|
-
{ url, status },
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function readContentLength(value: string | undefined, url: string, status: number): number | undefined {
|
|
747
|
-
if (value === undefined) return undefined;
|
|
748
|
-
const trimmed = value.trim();
|
|
749
|
-
if (!/^\d+$/u.test(trimmed)) {
|
|
750
|
-
throw new FusionWebFetchError('network_error', 'fusion_web_fetch invalid Content-Length header', { url, status });
|
|
751
|
-
}
|
|
752
|
-
return Number(trimmed);
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
async function extractContent(
|
|
756
|
-
body: Buffer,
|
|
757
|
-
contentType: string,
|
|
758
|
-
requestedFormat: 'text' | 'markdown',
|
|
759
|
-
): Promise<ExtractionResult> {
|
|
760
|
-
try {
|
|
761
|
-
const mediaType = mediaTypeFromContentType(contentType);
|
|
762
|
-
const decoded = decodeBody(body, contentType);
|
|
763
|
-
if (mediaType === 'text/plain') return { content: decoded, format: 'text' };
|
|
764
|
-
if (mediaType === 'text/markdown') return { content: decoded, format: 'markdown' };
|
|
765
|
-
if (requestedFormat === 'text') return { content: htmlToText(decoded), format: 'text' };
|
|
766
|
-
return { content: await htmlToMarkdown(decoded), format: 'markdown' };
|
|
767
|
-
} catch (error) {
|
|
768
|
-
if (error instanceof FusionWebFetchError) throw error;
|
|
769
|
-
throw new FusionWebFetchError(
|
|
770
|
-
'extraction_failed',
|
|
771
|
-
`fusion_web_fetch extraction failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
772
|
-
error instanceof Error ? { cause: error } : {},
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
function decodeBody(body: Buffer, contentType: string): string {
|
|
778
|
-
const charset = charsetFromContentType(contentType) ?? 'utf-8';
|
|
779
|
-
try {
|
|
780
|
-
return new TextDecoder(charset, { fatal: true, ignoreBOM: false }).decode(body);
|
|
781
|
-
} catch (error) {
|
|
782
|
-
throw new FusionWebFetchError(
|
|
783
|
-
'extraction_failed',
|
|
784
|
-
`fusion_web_fetch response body could not be decoded as ${charset}`,
|
|
785
|
-
error instanceof Error ? { cause: error } : {},
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function mediaTypeFromContentType(contentType: string): string {
|
|
791
|
-
return contentType.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function charsetFromContentType(contentType: string): string | undefined {
|
|
795
|
-
for (const parameter of contentType.split(';').slice(1)) {
|
|
796
|
-
const separator = parameter.indexOf('=');
|
|
797
|
-
if (separator < 0) continue;
|
|
798
|
-
const key = parameter.slice(0, separator).trim().toLowerCase();
|
|
799
|
-
if (key !== 'charset') continue;
|
|
800
|
-
const value = stripAsciiQuotes(parameter.slice(separator + 1).trim());
|
|
801
|
-
if (value.length === 0) throw new FusionWebFetchError('extraction_failed', 'fusion_web_fetch charset is empty');
|
|
802
|
-
return value;
|
|
803
|
-
}
|
|
804
|
-
return undefined;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function stripAsciiQuotes(value: string): string {
|
|
808
|
-
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1);
|
|
809
|
-
return value;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
async function htmlToMarkdown(html: string): Promise<string> {
|
|
813
|
-
const TurndownServiceClass = await loadTurndownService();
|
|
814
|
-
const stripped = stripUnsafeHtmlBlocks(html);
|
|
815
|
-
const tables = replaceTablesWithTokens(stripped);
|
|
816
|
-
const turndown = new TurndownServiceClass({ bulletListMarker: '-', codeBlockStyle: 'fenced', headingStyle: 'atx' });
|
|
817
|
-
let markdown = turndown.turndown(tables.html).trim();
|
|
818
|
-
for (const table of tables.replacements) {
|
|
819
|
-
markdown = markdown.replace(table.token, table.markdown);
|
|
820
|
-
}
|
|
821
|
-
return markdown.trim();
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
async function loadTurndownService(): Promise<TurndownServiceConstructor> {
|
|
825
|
-
if (turndownServiceLoad === undefined) {
|
|
826
|
-
turndownServiceLoad = import('turndown').then((module) => module.default);
|
|
827
|
-
}
|
|
828
|
-
try {
|
|
829
|
-
return await turndownServiceLoad;
|
|
830
|
-
} catch (error) {
|
|
831
|
-
turndownServiceLoad = undefined;
|
|
832
|
-
throw normalizeTurndownLoadError(error);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function normalizeTurndownLoadError(error: unknown): Error {
|
|
837
|
-
if (isMissingTurndownDependency(error)) {
|
|
838
|
-
const details = error instanceof Error ? { cause: error } : {};
|
|
839
|
-
return new FusionWebFetchError(
|
|
840
|
-
'extraction_failed',
|
|
841
|
-
'fusion_web_fetch markdown extraction dependency "turndown" is missing from pi-background-tasks; repair the package install with `pi update --extensions` or `npm install --omit=dev --prefix <pi-background-tasks>`.',
|
|
842
|
-
details,
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
|
-
if (error instanceof Error) return error;
|
|
846
|
-
return new Error(`fusion_web_fetch markdown extraction dependency failed to load: ${String(error)}`);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
function isMissingTurndownDependency(error: unknown): boolean {
|
|
850
|
-
const code = errorCode(error);
|
|
851
|
-
if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') return false;
|
|
852
|
-
return errorMessage(error).includes('turndown');
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function errorCode(error: unknown): string | undefined {
|
|
856
|
-
if (typeof error !== 'object' || error === null) return undefined;
|
|
857
|
-
const code = Reflect.get(error, 'code');
|
|
858
|
-
return typeof code === 'string' ? code : undefined;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
function errorMessage(error: unknown): string {
|
|
862
|
-
return error instanceof Error ? error.message : String(error);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
function htmlToText(html: string): string {
|
|
866
|
-
const withoutBlocks = stripUnsafeHtmlBlocks(html);
|
|
867
|
-
const withBreaks = withoutBlocks
|
|
868
|
-
.replace(/<br\s*\/?\s*>/giu, '\n')
|
|
869
|
-
.replace(/<\/(p|div|section|article|header|footer|li|tr|h[1-6]|pre)>/giu, '\n')
|
|
870
|
-
.replace(/<[^>]+>/gu, ' ');
|
|
871
|
-
return decodeHtmlEntities(withBreaks)
|
|
872
|
-
.split(/\r?\n/u)
|
|
873
|
-
.map((line) => line.replace(/[\t\f\v ]+/gu, ' ').trim())
|
|
874
|
-
.filter((line) => line.length > 0)
|
|
875
|
-
.join('\n');
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function stripUnsafeHtmlBlocks(html: string): string {
|
|
879
|
-
return html.replace(/<(script|style|noscript)\b[^>]*>[\s\S]*?<\/\1>/giu, '');
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
function replaceTablesWithTokens(html: string): { html: string; replacements: readonly TableReplacement[] } {
|
|
883
|
-
const replacements: TableReplacement[] = [];
|
|
884
|
-
const replaced = html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/giu, (tableHtml) => {
|
|
885
|
-
const markdown = tableToMarkdown(tableHtml);
|
|
886
|
-
if (markdown.length === 0) return '';
|
|
887
|
-
const token = `FUSIONWEBFETCHTABLE${String(replacements.length)}TOKEN`;
|
|
888
|
-
replacements.push({ token, markdown });
|
|
889
|
-
return `<p>${token}</p>`;
|
|
890
|
-
});
|
|
891
|
-
return { html: replaced, replacements };
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
function tableToMarkdown(tableHtml: string): string {
|
|
895
|
-
const rows: string[][] = [];
|
|
896
|
-
for (const rowMatch of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/giu)) {
|
|
897
|
-
const rowHtml = rowMatch[1] ?? '';
|
|
898
|
-
const cells = [...rowHtml.matchAll(/<(?:th|td)\b[^>]*>([\s\S]*?)<\/(?:th|td)>/giu)].map((cellMatch) =>
|
|
899
|
-
inlineHtmlToText(cellMatch[1] ?? ''),
|
|
900
|
-
);
|
|
901
|
-
if (cells.length > 0) rows.push(cells);
|
|
902
|
-
}
|
|
903
|
-
if (rows.length === 0) return '';
|
|
904
|
-
const columnCount = Math.max(...rows.map((row) => row.length));
|
|
905
|
-
const normalizedRows = rows.map((row) => [...row, ...Array.from({ length: columnCount - row.length }, () => '')]);
|
|
906
|
-
const header = normalizedRows[0] ?? [];
|
|
907
|
-
const separator = Array.from({ length: columnCount }, () => '---');
|
|
908
|
-
const bodyRows = normalizedRows.slice(1);
|
|
909
|
-
return [header, separator, ...bodyRows].map(markdownTableRow).join('\n');
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
function markdownTableRow(cells: readonly string[]): string {
|
|
913
|
-
return `| ${cells.map((cell) => cell.replace(/\|/gu, '\\|')).join(' | ')} |`;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
function inlineHtmlToText(html: string): string {
|
|
917
|
-
return decodeHtmlEntities(html.replace(/<[^>]+>/gu, ' ')).replace(/[\t\n\f\r ]+/gu, ' ').trim();
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
function decodeHtmlEntities(value: string): string {
|
|
921
|
-
return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/giu, (_match, entity: string) => {
|
|
922
|
-
const lower = entity.toLowerCase();
|
|
923
|
-
if (lower === 'amp') return '&';
|
|
924
|
-
if (lower === 'lt') return '<';
|
|
925
|
-
if (lower === 'gt') return '>';
|
|
926
|
-
if (lower === 'quot') return '"';
|
|
927
|
-
if (lower === 'apos') return "'";
|
|
928
|
-
if (lower === 'nbsp') return ' ';
|
|
929
|
-
if (lower.startsWith('#x')) return codePointToString(Number.parseInt(lower.slice(2), 16));
|
|
930
|
-
if (lower.startsWith('#')) return codePointToString(Number.parseInt(lower.slice(1), 10));
|
|
931
|
-
return `&${entity};`;
|
|
932
|
-
});
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
function codePointToString(value: number): string {
|
|
936
|
-
if (!Number.isFinite(value) || value < 0 || value > 0x10ffff) return '';
|
|
937
|
-
return String.fromCodePoint(value);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function capUtf8Bytes(content: string, maxBytes: number): { content: string; truncated: boolean } {
|
|
941
|
-
let usedBytes = 0;
|
|
942
|
-
let output = '';
|
|
943
|
-
for (const character of content) {
|
|
944
|
-
const nextBytes = Buffer.byteLength(character, 'utf8');
|
|
945
|
-
if (usedBytes + nextBytes > maxBytes) return { content: output, truncated: true };
|
|
946
|
-
usedBytes += nextBytes;
|
|
947
|
-
output += character;
|
|
948
|
-
}
|
|
949
|
-
return { content, truncated: false };
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function normalizeHostname(hostname: string): string {
|
|
953
|
-
const withoutBrackets = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
954
|
-
const lower = withoutBrackets.toLowerCase();
|
|
955
|
-
return lower.endsWith('.') ? lower.slice(0, -1) : lower;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
function classifyAddress(address: string): { public: boolean } {
|
|
959
|
-
const ipv4 = parseIpv4Address(address);
|
|
960
|
-
if (ipv4 !== undefined) return { public: !IPV4_DENY_RANGES.some(([base, prefix]) => ipv4InRange(ipv4, base, prefix)) };
|
|
961
|
-
const ipv6 = parseIpv6Address(address);
|
|
962
|
-
if (ipv6 !== undefined) return { public: !IPV6_DENY_RANGES.some(([base, prefix]) => ipv6InRange(ipv6, base, prefix)) };
|
|
963
|
-
return { public: false };
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
function addressMatches(expected: string, actual: string | undefined): boolean {
|
|
967
|
-
if (actual === undefined || actual.length === 0) return false;
|
|
968
|
-
const expectedComparable = comparableAddress(expected);
|
|
969
|
-
const actualComparable = comparableAddress(actual);
|
|
970
|
-
return (
|
|
971
|
-
expectedComparable !== undefined &&
|
|
972
|
-
actualComparable !== undefined &&
|
|
973
|
-
expectedComparable.family === actualComparable.family &&
|
|
974
|
-
expectedComparable.value === actualComparable.value
|
|
975
|
-
);
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
function comparableAddress(address: string): { family: 4 | 6; value: bigint } | undefined {
|
|
979
|
-
const ipv4 = parseIpv4Address(address);
|
|
980
|
-
if (ipv4 !== undefined) return { family: 4, value: BigInt(ipv4) };
|
|
981
|
-
const ipv6 = parseIpv6Address(address);
|
|
982
|
-
if (ipv6 === undefined) return undefined;
|
|
983
|
-
const mapped = ipv4FromMappedIpv6(ipv6);
|
|
984
|
-
if (mapped !== undefined) return { family: 4, value: BigInt(mapped) };
|
|
985
|
-
return { family: 6, value: ipv6 };
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function parseIpv4Address(address: string): number | undefined {
|
|
989
|
-
const pieces = address.split('.');
|
|
990
|
-
if (pieces.length !== 4) return undefined;
|
|
991
|
-
let value = 0;
|
|
992
|
-
for (const piece of pieces) {
|
|
993
|
-
if (!/^\d{1,3}$/u.test(piece)) return undefined;
|
|
994
|
-
const octet = Number(piece);
|
|
995
|
-
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return undefined;
|
|
996
|
-
value = value * 256 + octet;
|
|
997
|
-
}
|
|
998
|
-
return value;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
function ipv4ToNumberLiteral(address: string): number {
|
|
1002
|
-
const parsed = parseIpv4Address(address);
|
|
1003
|
-
if (parsed === undefined) throw new Error(`Invalid IPv4 literal in source: ${address}`);
|
|
1004
|
-
return parsed;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
function parseIpv6Address(address: string): bigint | undefined {
|
|
1008
|
-
if (address.includes('%')) return undefined;
|
|
1009
|
-
const rawSides = address.toLowerCase().split('::');
|
|
1010
|
-
if (rawSides.length > 2) return undefined;
|
|
1011
|
-
const left = parseIpv6Side(rawSides[0] ?? '');
|
|
1012
|
-
const right = parseIpv6Side(rawSides[1] ?? '');
|
|
1013
|
-
if (left === undefined || right === undefined) return undefined;
|
|
1014
|
-
const compressed = rawSides.length === 2;
|
|
1015
|
-
const missing = 8 - left.length - right.length;
|
|
1016
|
-
if ((!compressed && missing !== 0) || (compressed && missing < 1)) return undefined;
|
|
1017
|
-
const hextets = compressed ? [...left, ...Array.from({ length: missing }, () => 0), ...right] : left;
|
|
1018
|
-
if (hextets.length !== 8) return undefined;
|
|
1019
|
-
return hextets.reduce((value, hextet) => (value << 16n) + BigInt(hextet), 0n);
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
function parseIpv6Side(side: string): number[] | undefined {
|
|
1023
|
-
if (side.length === 0) return [];
|
|
1024
|
-
const parts = side.split(':');
|
|
1025
|
-
const hextets: number[] = [];
|
|
1026
|
-
for (const [index, part] of parts.entries()) {
|
|
1027
|
-
if (part.includes('.')) {
|
|
1028
|
-
if (index !== parts.length - 1) return undefined;
|
|
1029
|
-
const ipv4 = parseIpv4Address(part);
|
|
1030
|
-
if (ipv4 === undefined) return undefined;
|
|
1031
|
-
hextets.push(Math.floor(ipv4 / 0x10000), ipv4 % 0x10000);
|
|
1032
|
-
continue;
|
|
1033
|
-
}
|
|
1034
|
-
if (!/^[0-9a-f]{1,4}$/u.test(part)) return undefined;
|
|
1035
|
-
hextets.push(Number.parseInt(part, 16));
|
|
1036
|
-
}
|
|
1037
|
-
return hextets;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
function ipv6ToBigIntLiteral(address: string): bigint {
|
|
1041
|
-
const parsed = parseIpv6Address(address);
|
|
1042
|
-
if (parsed === undefined) throw new Error(`Invalid IPv6 literal in source: ${address}`);
|
|
1043
|
-
return parsed;
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
function ipv4FromMappedIpv6(address: bigint): number | undefined {
|
|
1047
|
-
const mappedPrefix = ipv6ToBigIntLiteral('::ffff:0:0');
|
|
1048
|
-
if (!ipv6InRange(address, mappedPrefix, 96)) return undefined;
|
|
1049
|
-
return Number(address & 0xffffffffn);
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
function ipv4InRange(address: number, base: number, prefix: number): boolean {
|
|
1053
|
-
const divisor = 2 ** (32 - prefix);
|
|
1054
|
-
return Math.floor(address / divisor) === Math.floor(base / divisor);
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
function ipv6InRange(address: bigint, base: bigint, prefix: number): boolean {
|
|
1058
|
-
const shift = BigInt(128 - prefix);
|
|
1059
|
-
return address >> shift === base >> shift;
|
|
1060
|
-
}
|